feat(tui): full ticket editor modal overlay
- Add _EditorField enum and _EditorState class for mutable field tracking - Add _Mode.editor and wire 'e' key in board+detail modes - Editor supports: title (inline text edit), type (◀/▶ selector), column (◀/▶ selector), labels (chips + add/delete), milestones (same), body (preview + launches $VISUAL/$EDITOR/vi in raw terminal) - j/k and arrow keys navigate between fields - h/l cycle selector values and move item cursor in multi-value lists - d removes selected label/milestone - s saves all fields via store.update(), returns to board with focus on ticket - Esc/q discards, returns to board - _renderEditor: centered modal overlay (max 76 wide), dim background, double-line border in column accent colour, 'unsaved' indicator when dirty Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
afc3d19ac9
commit
21d3814da4
2 changed files with 451 additions and 5 deletions
6
.project/kanban/backlog/DEW-0016.md
Normal file
6
.project/kanban/backlog/DEW-0016.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
id: DEW-0016
|
||||||
|
title: test
|
||||||
|
type: epic
|
||||||
|
created: 2026-04-25T17:56:14.779844Z
|
||||||
|
---
|
||||||
|
|
@ -11,7 +11,7 @@ import '../kanban_config.dart';
|
||||||
import '../ticket.dart';
|
import '../ticket.dart';
|
||||||
import '../ticket_store.dart';
|
import '../ticket_store.dart';
|
||||||
|
|
||||||
enum _Mode { board, detail }
|
enum _Mode { board, detail, editor }
|
||||||
|
|
||||||
// ── TUI event bus ────────────────────────────────────────────────────────────
|
// ── TUI event bus ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -39,6 +39,49 @@ class _Prompt {
|
||||||
_Prompt(this.kind, {this.input = '', this.ticketId});
|
_Prompt(this.kind, {this.input = '', this.ticketId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ticket editor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum _EditorField { title, type, column, labels, milestones, body }
|
||||||
|
|
||||||
|
class _EditorState {
|
||||||
|
final Ticket ticket;
|
||||||
|
String title;
|
||||||
|
String type;
|
||||||
|
String column;
|
||||||
|
List<String> labels;
|
||||||
|
List<String> milestones;
|
||||||
|
String body;
|
||||||
|
_EditorField focus;
|
||||||
|
int itemCursor; // index within the focused labels/milestones list
|
||||||
|
bool textEditing;
|
||||||
|
String textInput;
|
||||||
|
|
||||||
|
_EditorState.from(Ticket t)
|
||||||
|
: ticket = t,
|
||||||
|
title = t.title,
|
||||||
|
type = t.type,
|
||||||
|
column = t.column,
|
||||||
|
labels = [...t.labels],
|
||||||
|
milestones = [...t.milestones],
|
||||||
|
body = t.body,
|
||||||
|
focus = _EditorField.title,
|
||||||
|
itemCursor = 0,
|
||||||
|
textEditing = false,
|
||||||
|
textInput = '';
|
||||||
|
|
||||||
|
bool get isDirty =>
|
||||||
|
title != ticket.title ||
|
||||||
|
type != ticket.type ||
|
||||||
|
column != ticket.column ||
|
||||||
|
body != ticket.body ||
|
||||||
|
!_eq(labels, ticket.labels) ||
|
||||||
|
!_eq(milestones, ticket.milestones);
|
||||||
|
|
||||||
|
static bool _eq(List<String> a, List<String> b) =>
|
||||||
|
a.length == b.length &&
|
||||||
|
a.asMap().entries.every((e) => e.key < b.length && b[e.key] == e.value);
|
||||||
|
}
|
||||||
|
|
||||||
/// Parses raw terminal bytes into [Key] values without blocking.
|
/// Parses raw terminal bytes into [Key] values without blocking.
|
||||||
///
|
///
|
||||||
/// Mirrors [Console.readKey] but works on a pre-read byte batch so the event
|
/// Mirrors [Console.readKey] but works on a pre-read byte batch so the event
|
||||||
|
|
@ -190,6 +233,7 @@ class TuiCommand extends DewCommand {
|
||||||
var searchQuery = '';
|
var searchQuery = '';
|
||||||
var searchMode = false;
|
var searchMode = false;
|
||||||
_Prompt? prompt;
|
_Prompt? prompt;
|
||||||
|
_EditorState? editorState;
|
||||||
|
|
||||||
console.hideCursor();
|
console.hideCursor();
|
||||||
console.rawMode = true;
|
console.rawMode = true;
|
||||||
|
|
@ -200,7 +244,9 @@ class TuiCommand extends DewCommand {
|
||||||
console.clearScreen();
|
console.clearScreen();
|
||||||
console.resetCursorPosition();
|
console.resetCursorPosition();
|
||||||
|
|
||||||
if (mode == _Mode.board) {
|
if (mode == _Mode.editor && editorState != null) {
|
||||||
|
_renderEditor(console: console, config: config, es: editorState, w: w, h: h);
|
||||||
|
} else if (mode == _Mode.board) {
|
||||||
_renderBoard(
|
_renderBoard(
|
||||||
console: console,
|
console: console,
|
||||||
config: config,
|
config: config,
|
||||||
|
|
@ -253,7 +299,9 @@ class TuiCommand extends DewCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key stream — raw bytes converted to Key values without blocking.
|
// Key stream — raw bytes converted to Key values without blocking.
|
||||||
final keySub = io.stdin.listen((bytes) {
|
// Declared as var so it can be cancelled and re-created around external editor.
|
||||||
|
StreamSubscription<List<int>> keySub;
|
||||||
|
keySub = io.stdin.listen((bytes) {
|
||||||
for (final key in _parseKeys(bytes)) {
|
for (final key in _parseKeys(bytes)) {
|
||||||
if (!events.isClosed) events.add(_TuiKey(key));
|
if (!events.isClosed) events.add(_TuiKey(key));
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +432,210 @@ class TuiCommand extends DewCommand {
|
||||||
continue loop;
|
continue loop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Editor mode ────────────────────────────────────────────────────
|
||||||
|
if (mode == _Mode.editor && editorState != null) {
|
||||||
|
final es = editorState;
|
||||||
|
|
||||||
|
// ── Text editing sub-mode ────────────────────────────────────────
|
||||||
|
if (es.textEditing) {
|
||||||
|
if (key.isControl) {
|
||||||
|
switch (key.controlChar) {
|
||||||
|
case ControlCharacter.enter:
|
||||||
|
final v = es.textInput.trim();
|
||||||
|
if (v.isNotEmpty) {
|
||||||
|
switch (es.focus) {
|
||||||
|
case _EditorField.title:
|
||||||
|
es.title = v;
|
||||||
|
case _EditorField.labels:
|
||||||
|
if (!es.labels.contains(v)) es.labels.add(v);
|
||||||
|
es.itemCursor = es.labels.length - 1;
|
||||||
|
case _EditorField.milestones:
|
||||||
|
if (!es.milestones.contains(v)) es.milestones.add(v);
|
||||||
|
es.itemCursor = es.milestones.length - 1;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
es.textEditing = false;
|
||||||
|
es.textInput = '';
|
||||||
|
case ControlCharacter.escape:
|
||||||
|
es.textEditing = false;
|
||||||
|
es.textInput = '';
|
||||||
|
case ControlCharacter.backspace:
|
||||||
|
if (es.textInput.isNotEmpty) {
|
||||||
|
es.textInput = es.textInput.substring(0, es.textInput.length - 1);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
es.textInput += key.char;
|
||||||
|
}
|
||||||
|
redraw();
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normal editor navigation ─────────────────────────────────────
|
||||||
|
final allTypes = config.ticketTypes.map((t) => t.id).toList();
|
||||||
|
final allCols = config.columns.map((c) => c.id).toList();
|
||||||
|
|
||||||
|
if (!key.isControl) {
|
||||||
|
switch (key.char) {
|
||||||
|
case 'j':
|
||||||
|
es.focus = _EditorField.values[
|
||||||
|
(es.focus.index + 1) % _EditorField.values.length
|
||||||
|
];
|
||||||
|
es.itemCursor = 0;
|
||||||
|
case 'k':
|
||||||
|
es.focus = _EditorField.values[
|
||||||
|
(es.focus.index - 1 + _EditorField.values.length) % _EditorField.values.length
|
||||||
|
];
|
||||||
|
es.itemCursor = 0;
|
||||||
|
case 'h':
|
||||||
|
switch (es.focus) {
|
||||||
|
case _EditorField.type:
|
||||||
|
final i = allTypes.indexOf(es.type);
|
||||||
|
if (i > 0) es.type = allTypes[i - 1];
|
||||||
|
case _EditorField.column:
|
||||||
|
final i = allCols.indexOf(es.column);
|
||||||
|
if (i > 0) es.column = allCols[i - 1];
|
||||||
|
case _EditorField.labels:
|
||||||
|
if (es.labels.isNotEmpty && es.itemCursor > 0) es.itemCursor--;
|
||||||
|
case _EditorField.milestones:
|
||||||
|
if (es.milestones.isNotEmpty && es.itemCursor > 0) es.itemCursor--;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'l':
|
||||||
|
switch (es.focus) {
|
||||||
|
case _EditorField.type:
|
||||||
|
final i = allTypes.indexOf(es.type);
|
||||||
|
if (i < allTypes.length - 1) es.type = allTypes[i + 1];
|
||||||
|
case _EditorField.column:
|
||||||
|
final i = allCols.indexOf(es.column);
|
||||||
|
if (i < allCols.length - 1) es.column = allCols[i + 1];
|
||||||
|
case _EditorField.labels:
|
||||||
|
if (es.labels.isNotEmpty && es.itemCursor < es.labels.length - 1) {
|
||||||
|
es.itemCursor++;
|
||||||
|
}
|
||||||
|
case _EditorField.milestones:
|
||||||
|
if (es.milestones.isNotEmpty && es.itemCursor < es.milestones.length - 1) {
|
||||||
|
es.itemCursor++;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'd':
|
||||||
|
switch (es.focus) {
|
||||||
|
case _EditorField.labels:
|
||||||
|
if (es.labels.isNotEmpty) {
|
||||||
|
es.labels.removeAt(es.itemCursor.clamp(0, es.labels.length - 1));
|
||||||
|
es.itemCursor = es.itemCursor.clamp(0, max(0, es.labels.length - 1));
|
||||||
|
}
|
||||||
|
case _EditorField.milestones:
|
||||||
|
if (es.milestones.isNotEmpty) {
|
||||||
|
es.milestones.removeAt(es.itemCursor.clamp(0, es.milestones.length - 1));
|
||||||
|
es.itemCursor = es.itemCursor.clamp(0, max(0, es.milestones.length - 1));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 's':
|
||||||
|
// Save
|
||||||
|
try {
|
||||||
|
await store.update(
|
||||||
|
es.ticket.id,
|
||||||
|
title: es.title,
|
||||||
|
type: es.type,
|
||||||
|
column: es.column,
|
||||||
|
body: es.body,
|
||||||
|
labels: es.labels,
|
||||||
|
milestones: es.milestones,
|
||||||
|
);
|
||||||
|
tickets = await store.list();
|
||||||
|
byColumn = _groupByColumn(tickets, config);
|
||||||
|
// Find the ticket's new column & position
|
||||||
|
colIdx = config.columns.indexWhere((c) => c.id == es.column);
|
||||||
|
if (colIdx < 0) colIdx = 0;
|
||||||
|
final destTickets = byColumn[es.column] ?? [];
|
||||||
|
ticketIdx = max(0, destTickets.indexWhere((x) => x.id == es.ticket.id));
|
||||||
|
statusMsg = 'Ticket updated.';
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
statusMsg = 'Error: ${e.message ?? e}';
|
||||||
|
}
|
||||||
|
editorState = null;
|
||||||
|
mode = _Mode.board;
|
||||||
|
case 'q':
|
||||||
|
editorState = null;
|
||||||
|
mode = _Mode.board;
|
||||||
|
default:
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (key.controlChar) {
|
||||||
|
case ControlCharacter.ctrlC:
|
||||||
|
break loop;
|
||||||
|
case ControlCharacter.escape:
|
||||||
|
editorState = null;
|
||||||
|
mode = _Mode.board;
|
||||||
|
case ControlCharacter.arrowUp || ControlCharacter.arrowLeft:
|
||||||
|
es.focus = _EditorField.values[
|
||||||
|
(es.focus.index - 1 + _EditorField.values.length) % _EditorField.values.length
|
||||||
|
];
|
||||||
|
es.itemCursor = 0;
|
||||||
|
case ControlCharacter.arrowDown || ControlCharacter.arrowRight:
|
||||||
|
es.focus = _EditorField.values[
|
||||||
|
(es.focus.index + 1) % _EditorField.values.length
|
||||||
|
];
|
||||||
|
es.itemCursor = 0;
|
||||||
|
case ControlCharacter.enter:
|
||||||
|
// Enter starts text editing (title, labels, milestones) or opens external editor (body)
|
||||||
|
switch (es.focus) {
|
||||||
|
case _EditorField.title:
|
||||||
|
es.textInput = es.title;
|
||||||
|
es.textEditing = true;
|
||||||
|
case _EditorField.labels || _EditorField.milestones:
|
||||||
|
es.textInput = '';
|
||||||
|
es.textEditing = true;
|
||||||
|
case _EditorField.body:
|
||||||
|
// Launch external editor
|
||||||
|
final editor = io.Platform.environment['VISUAL'] ??
|
||||||
|
io.Platform.environment['EDITOR'] ??
|
||||||
|
'vi';
|
||||||
|
final tmpFile = io.File(
|
||||||
|
'${io.Directory.systemTemp.path}/dew_edit_${es.ticket.id}.md',
|
||||||
|
);
|
||||||
|
await tmpFile.writeAsString(es.body);
|
||||||
|
await keySub.cancel();
|
||||||
|
console.rawMode = false;
|
||||||
|
console.showCursor();
|
||||||
|
console.clearScreen();
|
||||||
|
final proc = await io.Process.start(
|
||||||
|
editor,
|
||||||
|
[tmpFile.path],
|
||||||
|
mode: io.ProcessStartMode.inheritStdio,
|
||||||
|
);
|
||||||
|
await proc.exitCode;
|
||||||
|
es.body = await tmpFile.readAsString();
|
||||||
|
await tmpFile.delete();
|
||||||
|
keySub = io.stdin.listen((bytes) {
|
||||||
|
for (final key in _parseKeys(bytes)) {
|
||||||
|
if (!events.isClosed) events.add(_TuiKey(key));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.rawMode = true;
|
||||||
|
console.hideCursor();
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redraw();
|
||||||
|
continue loop;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Board mode ─────────────────────────────────────────────────────
|
// ── Board mode ─────────────────────────────────────────────────────
|
||||||
if (mode == _Mode.board) {
|
if (mode == _Mode.board) {
|
||||||
final col = config.columns[colIdx];
|
final col = config.columns[colIdx];
|
||||||
|
|
@ -448,8 +700,8 @@ class TuiCommand extends DewCommand {
|
||||||
prompt = _Prompt(_PromptKind.newTitle);
|
prompt = _Prompt(_PromptKind.newTitle);
|
||||||
case 'e':
|
case 'e':
|
||||||
if (colTickets.isNotEmpty) {
|
if (colTickets.isNotEmpty) {
|
||||||
final t = colTickets[ticketIdx];
|
editorState = _EditorState.from(colTickets[ticketIdx]);
|
||||||
prompt = _Prompt(_PromptKind.editTitle, input: t.title, ticketId: t.id);
|
mode = _Mode.editor;
|
||||||
}
|
}
|
||||||
case 'c':
|
case 'c':
|
||||||
if (colTickets.isNotEmpty) {
|
if (colTickets.isNotEmpty) {
|
||||||
|
|
@ -508,6 +760,15 @@ class TuiCommand extends DewCommand {
|
||||||
case 'b':
|
case 'b':
|
||||||
mode = _Mode.board;
|
mode = _Mode.board;
|
||||||
detailScroll = 0;
|
detailScroll = 0;
|
||||||
|
case 'e':
|
||||||
|
final col = config.columns[colIdx];
|
||||||
|
final colTickets2 = _filtered(byColumn[col.id] ?? [], searchQuery);
|
||||||
|
if (colTickets2.isNotEmpty) {
|
||||||
|
editorState = _EditorState.from(
|
||||||
|
colTickets2[ticketIdx.clamp(0, colTickets2.length - 1)],
|
||||||
|
);
|
||||||
|
mode = _Mode.editor;
|
||||||
|
}
|
||||||
case 'j':
|
case 'j':
|
||||||
detailScroll++;
|
detailScroll++;
|
||||||
case 'k':
|
case 'k':
|
||||||
|
|
@ -1032,9 +1293,188 @@ class TuiCommand extends DewCommand {
|
||||||
return '${s.substring(0, maxLen - 1)}…';
|
return '${s.substring(0, maxLen - 1)}…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Ticket editor modal
|
||||||
|
// ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
static void _renderEditor({
|
||||||
|
required Console console,
|
||||||
|
required KanbanConfig config,
|
||||||
|
required _EditorState es,
|
||||||
|
required int w,
|
||||||
|
required int h,
|
||||||
|
}) {
|
||||||
|
final modalW = min(w - 4, 76);
|
||||||
|
const headerH = 2; // title bar + blank
|
||||||
|
const footerH = 2; // blank + hint bar
|
||||||
|
const fieldsCount = 6; // title, type, column, labels, milestones, body
|
||||||
|
const extraRows = 2; // extra padding rows
|
||||||
|
final modalH = headerH + fieldsCount + extraRows + footerH;
|
||||||
|
final modalLeft = ((w - modalW) ~/ 2) + 1;
|
||||||
|
final modalTop = max(1, ((h - modalH) ~/ 2));
|
||||||
|
final innerW = modalW - 2;
|
||||||
|
|
||||||
|
// Background dim — draw dim overlay (spaces) first
|
||||||
|
for (var row = 1; row <= h; row++) {
|
||||||
|
console.cursorPosition = Coordinate(row, 1);
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write('░' * w);
|
||||||
|
}
|
||||||
|
|
||||||
|
final esColCfg = config.columns.firstWhere(
|
||||||
|
(c) => c.id == es.column,
|
||||||
|
orElse: () => config.columns.first,
|
||||||
|
);
|
||||||
|
final accentColor = _colColor(esColCfg.color);
|
||||||
|
|
||||||
|
void at(int row, int col, void Function() fn) {
|
||||||
|
console.cursorPosition = Coordinate(row, col);
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawBorder() {
|
||||||
|
final topBar = '╔${'═' * innerW}╗';
|
||||||
|
final botBar = '╚${'═' * innerW}╝';
|
||||||
|
at(modalTop, modalLeft, () {
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write(topBar);
|
||||||
|
});
|
||||||
|
for (var r = 1; r < modalH - 1; r++) {
|
||||||
|
at(modalTop + r, modalLeft, () {
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write('║');
|
||||||
|
console.resetColorAttributes();
|
||||||
|
console.write(' ' * innerW);
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write('║');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
at(modalTop + modalH - 1, modalLeft, () {
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write(botBar);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBorder();
|
||||||
|
|
||||||
|
// Header bar
|
||||||
|
final headerText = ' ✏ Edit ${es.ticket.id} ';
|
||||||
|
final paddedHeader = headerText.padRight(innerW);
|
||||||
|
at(modalTop + 1, modalLeft + 1, () {
|
||||||
|
console.setForegroundColor(ConsoleColor.black);
|
||||||
|
console.setBackgroundColor(accentColor);
|
||||||
|
console.writeLine(paddedHeader.substring(0, min(paddedHeader.length, innerW)));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
});
|
||||||
|
|
||||||
|
final textColor = ConsoleColor.white;
|
||||||
|
|
||||||
|
void fieldRow(int relRow, _EditorField field, String label, String value,
|
||||||
|
{bool isSelector = false, bool isMulti = false, List<String> items = const [], int itemCursor = 0}) {
|
||||||
|
final focused = es.focus == field;
|
||||||
|
final prefix = focused ? '❯ ' : ' ';
|
||||||
|
at(modalTop + 3 + relRow, modalLeft + 1, () {
|
||||||
|
// Label
|
||||||
|
if (focused) {
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write(prefix);
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write('${label.padRight(12)} ');
|
||||||
|
} else {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write(prefix);
|
||||||
|
console.setForegroundColor(ConsoleColor.white);
|
||||||
|
console.write('${label.padRight(12)} ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value
|
||||||
|
if (es.textEditing && focused) {
|
||||||
|
// Editing inline — show input with cursor
|
||||||
|
console.setForegroundColor(ConsoleColor.black);
|
||||||
|
console.setBackgroundColor(ConsoleColor.white);
|
||||||
|
final inputDisplay = '${es.textInput}▌';
|
||||||
|
console.write(inputDisplay.padRight(min(innerW - 14, 40)));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
} else if (isSelector) {
|
||||||
|
console.setForegroundColor(focused ? accentColor : textColor);
|
||||||
|
final allVals = field == _EditorField.type
|
||||||
|
? config.ticketTypes.map((t) => t.id).toList()
|
||||||
|
: config.columns.map((c) => c.id).toList();
|
||||||
|
final idx = allVals.indexOf(value);
|
||||||
|
final prev = idx > 0 ? '◀ ' : ' ';
|
||||||
|
final next = idx < allVals.length - 1 ? ' ▶' : ' ';
|
||||||
|
console.write('$prev$value$next');
|
||||||
|
} else if (isMulti) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write('(none) ');
|
||||||
|
if (focused) {
|
||||||
|
console.setForegroundColor(accentColor);
|
||||||
|
console.write(' [Enter to add]');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
final sel = focused && i == itemCursor;
|
||||||
|
if (sel) {
|
||||||
|
console.setForegroundColor(ConsoleColor.black);
|
||||||
|
console.setBackgroundColor(accentColor);
|
||||||
|
console.write(' ${items[i]} ');
|
||||||
|
console.resetColorAttributes();
|
||||||
|
} else {
|
||||||
|
console.setForegroundColor(focused ? textColor : ConsoleColor.brightBlack);
|
||||||
|
console.write('• ${items[i]} ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (focused) {
|
||||||
|
console.resetColorAttributes();
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write(' [Enter +] [d] del');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain text field (title, body preview)
|
||||||
|
console.setForegroundColor(focused ? accentColor : textColor);
|
||||||
|
final disp = value.isNotEmpty ? value : '(empty)';
|
||||||
|
final maxLen = innerW - 15;
|
||||||
|
console.write(_trunc(disp, maxLen));
|
||||||
|
if (focused && field != _EditorField.body) {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write(' [Enter to edit]');
|
||||||
|
} else if (focused && field == _EditorField.body) {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write(' [Enter → \$EDITOR]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.resetColorAttributes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldRow(0, _EditorField.title, 'Title', es.title);
|
||||||
|
fieldRow(1, _EditorField.type, 'Type', es.type, isSelector: true);
|
||||||
|
fieldRow(2, _EditorField.column, 'Column', es.column, isSelector: true);
|
||||||
|
fieldRow(3, _EditorField.labels, 'Labels', '', isMulti: true, items: es.labels, itemCursor: es.itemCursor);
|
||||||
|
fieldRow(4, _EditorField.milestones, 'Milestones', '', isMulti: true, items: es.milestones, itemCursor: es.itemCursor);
|
||||||
|
|
||||||
|
// Body row — show first line preview
|
||||||
|
final bodyPreview = es.body.isNotEmpty
|
||||||
|
? es.body.split('\n').first
|
||||||
|
: '';
|
||||||
|
fieldRow(5, _EditorField.body, 'Body', bodyPreview);
|
||||||
|
|
||||||
|
// Footer hints
|
||||||
|
final dirtyMarker = es.isDirty ? ' ● unsaved' : '';
|
||||||
|
final footerHints = '[j/k] field [h/l] value [Enter] edit [d] del [s] save [Esc] discard$dirtyMarker';
|
||||||
|
at(modalTop + modalH - 2, modalLeft + 1, () {
|
||||||
|
console.setForegroundColor(ConsoleColor.brightBlack);
|
||||||
|
console.write(_trunc(footerHints, innerW));
|
||||||
|
console.resetColorAttributes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static String _fmtDate(DateTime dt) =>
|
static String _fmtDate(DateTime dt) =>
|
||||||
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
|
||||||
static List<String> _wordWrap(String text, int width) {
|
static List<String> _wordWrap(String text, int width) {
|
||||||
if (text.isEmpty) return [''];
|
if (text.isEmpty) return [''];
|
||||||
if (text.length <= width) return [text];
|
if (text.length <= width) return [text];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue