torrent-tui 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,23 @@
1
- import { readdirSync, existsSync } from "node:fs";
1
+ import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
4
4
  import { getTheme } from "../theme";
5
5
  import type { LayoutDimensions } from "../types/layout";
6
6
  import { resolvePath } from "../utils/paths";
7
7
 
8
- const DIALOG_WIDTH = 60;
8
+ const DIALOG_WIDTH = 60;
9
9
  const DIALOG_HEIGHT = 16;
10
- const INNER_W = DIALOG_WIDTH - 2;
11
- const MARGIN = 2;
10
+ const INNER_W = DIALOG_WIDTH - 2;
11
+ const MARGIN = 2;
12
12
 
13
13
  function truncateName(name: string): string {
14
14
  const max = INNER_W - MARGIN * 2;
15
- return name.length > max ? name.slice(0, max - 1) + "…" : name;
15
+ return name.length > max ? `${name.slice(0, max - 1)}…` : name;
16
16
  }
17
17
 
18
18
  function setBg(node: BoxRenderable, bg: string | undefined): void {
19
- (node as unknown as { backgroundColor: string | undefined }).backgroundColor = bg;
19
+ (node as unknown as { backgroundColor: string | undefined }).backgroundColor =
20
+ bg;
20
21
  }
21
22
 
22
23
  export class AddTorrentDialog {
@@ -31,7 +32,11 @@ export class AddTorrentDialog {
31
32
 
32
33
  onSelect?: (filePath: string) => void;
33
34
 
34
- constructor(renderer: CliRenderer, layout: LayoutDimensions, torrentFolder: string) {
35
+ constructor(
36
+ renderer: CliRenderer,
37
+ layout: LayoutDimensions,
38
+ torrentFolder: string,
39
+ ) {
35
40
  this.renderer = renderer;
36
41
  this.layout = layout;
37
42
  this.torrentFolder = resolvePath(torrentFolder);
@@ -93,14 +98,23 @@ export class AddTorrentDialog {
93
98
  private updateHighlight(): void {
94
99
  const theme = getTheme();
95
100
  for (let i = 0; i < this.itemRows.length; i++) {
96
- setBg(this.itemRows[i]!, i === this.selectedIndex ? theme.bgTertiary : undefined);
101
+ setBg(
102
+ this.itemRows[i]!,
103
+ i === this.selectedIndex ? theme.bgTertiary : undefined,
104
+ );
97
105
  }
98
106
  }
99
107
 
100
108
  private build(): void {
101
109
  const theme = getTheme();
102
- const left = Math.max(0, Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2));
103
- const top = Math.max(0, Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2));
110
+ const left = Math.max(
111
+ 0,
112
+ Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2),
113
+ );
114
+ const top = Math.max(
115
+ 0,
116
+ Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2),
117
+ );
104
118
 
105
119
  const container = new BoxRenderable(this.renderer, {
106
120
  position: "absolute",
@@ -122,8 +136,18 @@ export class AddTorrentDialog {
122
136
  paddingLeft: MARGIN,
123
137
  paddingRight: MARGIN,
124
138
  });
125
- titleRow.add(new TextRenderable(this.renderer, { content: "Add Torrent", fg: theme.accent }));
126
- titleRow.add(new TextRenderable(this.renderer, { content: "Esc to close", fg: theme.fgMuted }));
139
+ titleRow.add(
140
+ new TextRenderable(this.renderer, {
141
+ content: "Add Torrent",
142
+ fg: theme.accent,
143
+ }),
144
+ );
145
+ titleRow.add(
146
+ new TextRenderable(this.renderer, {
147
+ content: "Esc to close",
148
+ fg: theme.fgMuted,
149
+ }),
150
+ );
127
151
  container.add(titleRow);
128
152
 
129
153
  // Spacer
@@ -132,22 +156,27 @@ export class AddTorrentDialog {
132
156
  this.itemRows = [];
133
157
 
134
158
  if (this.files.length === 0) {
135
- container.add(new TextRenderable(this.renderer, {
136
- content: " ".repeat(MARGIN) + `No .torrent files in ${this.torrentFolder}`,
137
- fg: theme.fgMuted,
138
- }));
159
+ container.add(
160
+ new TextRenderable(this.renderer, {
161
+ content: `${" ".repeat(MARGIN)}No .torrent files in ${this.torrentFolder}`,
162
+ fg: theme.fgMuted,
163
+ }),
164
+ );
139
165
  } else {
140
166
  for (let i = 0; i < this.files.length; i++) {
141
167
  const file = this.files[i]!;
142
168
  const row = new BoxRenderable(this.renderer, {
143
169
  width: INNER_W,
144
170
  height: 1,
145
- backgroundColor: i === this.selectedIndex ? theme.bgTertiary : undefined,
171
+ backgroundColor:
172
+ i === this.selectedIndex ? theme.bgTertiary : undefined,
146
173
  });
147
- row.add(new TextRenderable(this.renderer, {
148
- content: " ".repeat(MARGIN) + truncateName(file.name),
149
- fg: i === this.selectedIndex ? theme.fgPrimary : theme.fgSecondary,
150
- }));
174
+ row.add(
175
+ new TextRenderable(this.renderer, {
176
+ content: " ".repeat(MARGIN) + truncateName(file.name),
177
+ fg: i === this.selectedIndex ? theme.fgPrimary : theme.fgSecondary,
178
+ }),
179
+ );
151
180
  container.add(row);
152
181
  this.itemRows.push(row);
153
182
  }
@@ -2,7 +2,7 @@ import { BoxRenderable, type CliRenderer, TextRenderable } from "@opentui/core";
2
2
  import { getTheme } from "../theme";
3
3
  import type { LayoutDimensions } from "../types/layout";
4
4
 
5
- const DIALOG_WIDTH = 38;
5
+ const DIALOG_WIDTH = 38;
6
6
  const DIALOG_HEIGHT = 7;
7
7
 
8
8
  export class ConfirmDialog {
@@ -44,7 +44,13 @@ export class ConfirmDialog {
44
44
 
45
45
  handleInput(key: string): boolean {
46
46
  if (!this.isOpen) return false;
47
- if (key === "tab" || key === "h" || key === "l" || key === "left" || key === "right") {
47
+ if (
48
+ key === "tab" ||
49
+ key === "h" ||
50
+ key === "l" ||
51
+ key === "left" ||
52
+ key === "right"
53
+ ) {
48
54
  this.focusedBtn = this.focusedBtn === "confirm" ? "cancel" : "confirm";
49
55
  this.updateButtons();
50
56
  return true;
@@ -85,8 +91,14 @@ export class ConfirmDialog {
85
91
 
86
92
  private build(message: string): void {
87
93
  const theme = getTheme();
88
- const left = Math.max(0, Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2));
89
- const top = Math.max(0, Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2));
94
+ const left = Math.max(
95
+ 0,
96
+ Math.floor((this.layout.terminal.width - DIALOG_WIDTH) / 2),
97
+ );
98
+ const top = Math.max(
99
+ 0,
100
+ Math.floor((this.layout.terminal.height - DIALOG_HEIGHT) / 2),
101
+ );
90
102
 
91
103
  const container = new BoxRenderable(this.renderer, {
92
104
  position: "absolute",
@@ -101,17 +113,27 @@ export class ConfirmDialog {
101
113
 
102
114
  const inner = DIALOG_WIDTH - 2;
103
115
 
104
- container.add(new TextRenderable(this.renderer, { content: " ".repeat(inner) }));
105
- container.add(new TextRenderable(this.renderer, {
106
- content: (" " + message).padEnd(inner),
107
- fg: theme.fgPrimary,
108
- }));
109
- container.add(new TextRenderable(this.renderer, { content: " ".repeat(inner) }));
110
- container.add(new TextRenderable(this.renderer, {
111
- content: " Files will be deleted from disk.".padEnd(inner),
112
- fg: theme.fgSecondary,
113
- }));
114
- container.add(new TextRenderable(this.renderer, { content: " ".repeat(inner) }));
116
+ container.add(
117
+ new TextRenderable(this.renderer, { content: " ".repeat(inner) }),
118
+ );
119
+ container.add(
120
+ new TextRenderable(this.renderer, {
121
+ content: ` ${message}`.padEnd(inner),
122
+ fg: theme.fgPrimary,
123
+ }),
124
+ );
125
+ container.add(
126
+ new TextRenderable(this.renderer, { content: " ".repeat(inner) }),
127
+ );
128
+ container.add(
129
+ new TextRenderable(this.renderer, {
130
+ content: " Files will be deleted from disk.".padEnd(inner),
131
+ fg: theme.fgSecondary,
132
+ }),
133
+ );
134
+ container.add(
135
+ new TextRenderable(this.renderer, { content: " ".repeat(inner) }),
136
+ );
115
137
 
116
138
  const btnRow = new BoxRenderable(this.renderer, {
117
139
  width: inner,
@@ -3,56 +3,95 @@ import type { Store } from "../store";
3
3
  import { getTheme } from "../theme";
4
4
  import type { LayoutDimensions } from "../types/layout";
5
5
  import { filterTorrents } from "../utils/filter";
6
+ import { DetailPanel, type DetailTab } from "./detail-panel";
6
7
  import { TorrentTable } from "./torrent-view";
7
8
 
8
- function innerLayout(layout: LayoutDimensions): LayoutDimensions {
9
- return {
10
- ...layout,
11
- content: {
12
- ...layout.content,
13
- width: layout.content.width - 2,
14
- height: layout.content.height - 2,
15
- },
16
- };
17
- }
9
+ export type FocusArea = "sidebar" | "table" | "details";
18
10
 
19
11
  export class ContentWindow {
20
12
  private renderer: CliRenderer;
21
13
  private store: Store;
22
14
  private layout: LayoutDimensions;
23
15
  private container: BoxRenderable;
16
+ private tableFrame: BoxRenderable;
17
+ private tableInner!: BoxRenderable;
24
18
  private torrentTable: TorrentTable;
19
+ private detailPanel: DetailPanel;
25
20
 
26
21
  constructor(renderer: CliRenderer, store: Store, layout: LayoutDimensions) {
27
22
  this.renderer = renderer;
28
23
  this.store = store;
29
24
  this.layout = layout;
30
- this.torrentTable = new TorrentTable(renderer, innerLayout(layout));
31
- this.container = this.build();
25
+ this.torrentTable = new TorrentTable(renderer, tableLayout(layout));
26
+ this.detailPanel = new DetailPanel(renderer, detailLayout(layout));
27
+ const built = this.build();
28
+ this.container = built.container;
29
+ this.tableFrame = built.tableFrame;
32
30
  this.renderer.root.add(this.container);
33
31
  }
34
32
 
35
- update(focusArea: "sidebar" | "table", selectedIndex: number): void {
33
+ update(
34
+ focusArea: FocusArea,
35
+ selectedIndex: number,
36
+ detailTab: DetailTab = "Pieces",
37
+ detailScrollOffset = 0,
38
+ ): void {
36
39
  const theme = getTheme();
37
40
  const state = this.store.getState();
38
- (this.container as unknown as { borderColor: string }).borderColor =
41
+ (this.tableFrame as unknown as { borderColor: string }).borderColor =
39
42
  focusArea === "table" ? theme.accent : theme.border;
40
43
  const visible = filterTorrents(state.torrents, state.selectedView);
41
44
  this.torrentTable.update(visible, selectedIndex, focusArea);
45
+ const selectedTorrent =
46
+ focusArea === "sidebar" ? null : (visible[selectedIndex] ?? null);
47
+ const detailPlaceholder = resolveDetailPlaceholder(
48
+ focusArea,
49
+ state.torrents.length,
50
+ visible.length,
51
+ selectedTorrent !== null,
52
+ );
53
+ this.detailPanel.update(
54
+ selectedTorrent,
55
+ detailTab,
56
+ focusArea === "details",
57
+ detailScrollOffset,
58
+ detailPlaceholder,
59
+ );
60
+ }
61
+
62
+ getDetailBodyRowCount(): number {
63
+ return this.detailPanel.getBodyRowCount();
42
64
  }
43
65
 
44
66
  updateLayout(layout: LayoutDimensions): void {
45
67
  this.layout = layout;
46
68
  (this.container as unknown as { left: number }).left = layout.content.x;
47
69
  (this.container as unknown as { top: number }).top = layout.content.y;
48
- (this.container as unknown as { width: number }).width = layout.content.width;
49
- (this.container as unknown as { height: number }).height = layout.content.height;
50
- this.torrentTable.updateLayout(innerLayout(layout));
70
+ (this.container as unknown as { width: number }).width =
71
+ layout.content.width;
72
+ (this.container as unknown as { height: number }).height =
73
+ layout.content.height;
74
+
75
+ const table = tableFrameLayout(layout);
76
+ (this.tableFrame as unknown as { left: number }).left = table.content.x;
77
+ (this.tableFrame as unknown as { top: number }).top = table.content.y;
78
+ (this.tableFrame as unknown as { width: number }).width =
79
+ table.content.width;
80
+ (this.tableFrame as unknown as { height: number }).height =
81
+ table.content.height;
82
+ (this.tableInner as unknown as { width: number }).width =
83
+ table.content.width - 2;
84
+ (this.tableInner as unknown as { height: number }).height =
85
+ table.content.height - 2;
86
+
87
+ this.torrentTable.updateLayout(tableLayout(layout));
88
+ this.detailPanel.updateLayout(detailLayout(layout));
51
89
  }
52
90
 
53
- private build(): BoxRenderable {
91
+ private build(): { container: BoxRenderable; tableFrame: BoxRenderable } {
54
92
  const theme = getTheme();
55
93
  const layout = this.layout;
94
+ const table = tableFrameLayout(layout);
56
95
 
57
96
  const container = new BoxRenderable(this.renderer, {
58
97
  position: "absolute",
@@ -60,21 +99,87 @@ export class ContentWindow {
60
99
  top: layout.content.y,
61
100
  width: layout.content.width,
62
101
  height: layout.content.height,
102
+ });
103
+
104
+ const tableFrame = new BoxRenderable(this.renderer, {
105
+ position: "absolute",
106
+ left: table.content.x,
107
+ top: table.content.y,
108
+ width: table.content.width,
109
+ height: table.content.height,
63
110
  border: true,
64
111
  borderColor: theme.border,
65
112
  });
66
113
 
67
- // Inner wrapper offset by 1 on each side to sit inside the border
68
- const inner = new BoxRenderable(this.renderer, {
114
+ this.tableInner = new BoxRenderable(this.renderer, {
69
115
  position: "absolute",
70
116
  left: 1,
71
117
  top: 1,
72
- width: layout.content.width - 2,
73
- height: layout.content.height - 2,
118
+ width: table.content.width - 2,
119
+ height: table.content.height - 2,
74
120
  });
75
- inner.add(this.torrentTable.getContainer());
76
- container.add(inner);
121
+ this.tableInner.add(this.torrentTable.getContainer());
122
+ tableFrame.add(this.tableInner);
123
+
124
+ container.add(tableFrame);
125
+ container.add(this.detailPanel.getContainer());
77
126
 
78
- return container;
127
+ return { container, tableFrame };
79
128
  }
80
129
  }
130
+
131
+ function resolveDetailPlaceholder(
132
+ focusArea: FocusArea,
133
+ totalTorrents: number,
134
+ visibleTorrents: number,
135
+ hasSelectedTorrent: boolean,
136
+ ): string | null {
137
+ if (hasSelectedTorrent) return null;
138
+ if (totalTorrents === 0) return "No torrents added yet";
139
+ if (visibleTorrents === 0) return "Select a torrent to inspect details";
140
+ if (focusArea === "sidebar") return "Select a torrent to inspect details";
141
+ return "No torrent selected";
142
+ }
143
+
144
+ function tableLayout(layout: LayoutDimensions): LayoutDimensions {
145
+ const frame = tableFrameLayout(layout);
146
+ return {
147
+ ...layout,
148
+ content: {
149
+ x: 0,
150
+ y: 0,
151
+ width: frame.content.width - 2,
152
+ height: frame.content.height - 2,
153
+ },
154
+ };
155
+ }
156
+
157
+ function detailLayout(layout: LayoutDimensions): LayoutDimensions {
158
+ const detailHeight = getDetailHeight(layout.content.height);
159
+ return {
160
+ ...layout,
161
+ content: {
162
+ x: 0,
163
+ y: layout.content.height - detailHeight,
164
+ width: layout.content.width,
165
+ height: detailHeight,
166
+ },
167
+ };
168
+ }
169
+
170
+ function tableFrameLayout(layout: LayoutDimensions): LayoutDimensions {
171
+ const detailHeight = getDetailHeight(layout.content.height);
172
+ return {
173
+ ...layout,
174
+ content: {
175
+ x: 0,
176
+ y: 0,
177
+ width: layout.content.width,
178
+ height: Math.max(5, layout.content.height - detailHeight),
179
+ },
180
+ };
181
+ }
182
+
183
+ function getDetailHeight(contentHeight: number): number {
184
+ return Math.max(6, Math.min(10, Math.floor(contentHeight * 0.35)));
185
+ }