Skip to content
Merged
File renamed without changes.
97 changes: 97 additions & 0 deletions lib/core/erd/erd_group_ops.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import 'package:querya_desktop/core/erd/erd_saved_layout.dart';

/// Operations on the user's table groups as functions of a list (#1362): each
/// returns a new list and never touches its argument, so the rules can be
/// tested without a widget tree.
abstract final class ErdGroupOps {
/// The group [table] belongs to, if any.
static ErdGroup? groupOf(List<ErdGroup> groups, String table) {
for (final g in groups) {
if (g.tables.contains(table)) return g;
}
return null;
}

/// [tables] out of every group but [except]; a group left empty goes.
static List<ErdGroup> ungroupTables(
List<ErdGroup> groups,
Set<String> tables, {
String? except,
}) {
final out = List.of(groups);
for (var i = out.length - 1; i >= 0; i--) {
final g = out[i];
if (g.id == except || !g.tables.any(tables.contains)) continue;
final left = [for (final t in g.tables) if (!tables.contains(t)) t];
if (left.isEmpty) {
out.removeAt(i);
} else {
out[i] = g.copyWith(tables: left);
}
}
return out;
}

/// The number for the next default group name, `Group <n>`.
static int nextNameNumber(List<ErdGroup> groups) {
var n = groups.length + 1;
while (groups.any((g) => g.name == 'Group $n')) {
n++;
}
return n;
}

/// The number for the next group id, `g<n>`; it also picks the colour.
static int nextIdNumber(List<ErdGroup> groups) {
var id = 1;
while (groups.any((g) => g.id == 'g$id')) {
id++;
}
return id;
}

/// [groups] with [tables] taken out of their groups and put in a new group.
/// [tableNames] is every table of the diagram in its order, so the group
/// keeps that order.
static List<ErdGroup> withNewGroup(
List<ErdGroup> groups, {
required Set<String> tables,
required Iterable<String> tableNames,
required String name,
String? note,
}) {
final id = nextIdNumber(groups);
return [
...ungroupTables(groups, tables),
ErdGroup(
id: 'g$id',
name: name,
note: note,
color: erdHeaderSlots[(id - 1) % erdHeaderSlots.length],
tables: [
for (final t in tableNames)
if (tables.contains(t)) t,
],
),
];
}

/// [groups] with [tables] taken out of the other groups and added to the
/// group [id]. Unchanged apart from the ungrouping when [id] is unknown.
static List<ErdGroup> addTables(
List<ErdGroup> groups,
String id,
Set<String> tables,
) {
final out = ungroupTables(groups, tables, except: id);
final i = out.indexWhere((g) => g.id == id);
if (i < 0) return out;
final g = out[i];
out[i] = g.copyWith(tables: [
...g.tables,
for (final t in tables)
if (!g.tables.contains(t)) t,
]);
return out;
}
}
File renamed without changes.
125 changes: 125 additions & 0 deletions lib/core/erd/erd_layout_engine.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import 'dart:math' show max, min;

import 'package:querya_desktop/core/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';
import 'package:querya_desktop/core/erd/erd_saved_layout.dart';

/// The pure, widget-free part of the diagram's logic (#1362): what is visible,
/// how saved positions merge with a computed layout, which tables are related
/// and which match a search. No Flutter widgets, so it is unit-testable
/// without a widget tester.
abstract final class ErdLayoutEngine {
/// The focused table as named in [schema]: `public.orders` falls back to
/// `orders` when the current schema names it without a prefix.
static String? focusIn(ErdSchema schema, String? focusTable) {
final f = focusTable;
if (f == null) return null;
if (schema.tables.any((t) => t.name == f)) return f;
final bare = f.contains('.') ? f.substring(f.indexOf('.') + 1) : f;
return schema.tables.any((t) => t.name == bare) ? bare : null;
}

/// The schema as drawn: [hidden] tables gone, columns cut to keys in
/// keys-only mode, and [collapsed] cards without columns.
static ErdSchema visibleOf(
ErdSchema schema, {
required Set<String> hidden,
required Set<String> collapsed,
required ErdDetail detail,
}) {
final tables = [
for (final t in schema.tables)
if (!hidden.contains(t.name))
ErdTable(
name: t.name,
comment: t.comment,
columns: collapsed.contains(t.name) || detail == ErdDetail.names
? const []
: [
for (final c in t.columns)
if (detail == ErdDetail.all ||
c.isPrimaryKey ||
c.isForeignKey)
c,
],
),
];
final names = {for (final t in tables) t.name};
return ErdSchema(
tables: tables,
relations: [
for (final r in schema.relations)
if (names.contains(r.fromTable) && names.contains(r.toTable)) r,
],
);
}

/// [computed] with the positions [saved] holds. Tables the saved layout
/// does not know (new in the database) keep their computed arrangement,
/// moved right of the saved cards so nothing overlaps.
static ErdLayout withSaved(ErdLayout computed, ErdSavedLayout? saved) {
if (saved == null || saved.positions.isEmpty) return computed;
var layout = computed;
var savedRight = 0.0;
double? newLeft;
for (final e in computed.positions.entries) {
final p = saved.positions[e.key];
if (p != null) {
savedRight = max(savedRight, p.dx + computed.widthFor(e.key));
} else {
newLeft = newLeft == null ? e.value.dx : min(newLeft, e.value.dx);
}
}
final shift =
newLeft == null ? 0.0 : savedRight + ErdLayout.layerGap - newLeft;
for (final e in computed.positions.entries) {
final p = saved.positions[e.key];
layout = layout.withPosition(e.key, p ?? e.value.translate(shift, 0));
}
return layout;
}

/// For a many-to-many link table: the tables it links, `users and roles`.
static Map<String, String> junctionMap(ErdSchema schema) => {
for (final t in schema.tables)
if (t.isJunction)
t.name: {
for (final r in schema.relations)
if (r.fromTable == t.name) r.toTable,
}.join(' and '),
};

/// Tables related to each table by a foreign key, either way. Built once per
/// build, so the focus test is a set lookup per card.
static Map<String, Set<String>> neighbourMap(ErdSchema schema) {
final out = <String, Set<String>>{};
for (final r in schema.relations) {
out.putIfAbsent(r.fromTable, () => {}).add(r.toTable);
out.putIfAbsent(r.toTable, () => {}).add(r.fromTable);
}
return out;
}

/// Whether [table] is the focus or related to it.
static bool isFocusedIn(
Map<String, Set<String>> neighbours,
String? focus,
String table,
) {
if (focus == null) return false;
return focus == table || (neighbours[focus]?.contains(table) ?? false);
}

/// The tables whose name contains [query], at most [limit].
static List<ErdTable> matches(
ErdSchema schema,
String query, {
int limit = 8,
}) {
final q = query.toLowerCase();
return [
for (final t in schema.tables)
if (t.name.toLowerCase().contains(q)) t,
].take(limit).toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui';

import 'package:querya_desktop/features/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';

/// One routed relation: an orthogonal polyline from the FK column row of
Expand Down
2 changes: 1 addition & 1 deletion lib/features/erd/erd_card_measure.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'package:flutter/painting.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';
import 'package:querya_desktop/core/theme/querya_typography.dart';
import 'package:querya_desktop/features/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_layout.dart';

/// Measures what a table card shows with the card's own fonts, so the card is
/// exactly as wide as its header and its widest `name type` row (#1276).
Expand Down
6 changes: 3 additions & 3 deletions lib/features/erd/erd_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import 'dart:ui' show Offset, Rect, Size;
import 'package:querya_desktop/core/erd/erd_note_text.dart';
import 'package:querya_desktop/core/erd/erd_saved_layout.dart'
show ErdGroup, ErdNote;
import 'package:querya_desktop/features/erd/erd_geometry.dart';
import 'package:querya_desktop/features/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_geometry.dart';
import 'package:querya_desktop/core/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';
import 'package:querya_desktop/features/erd/erd_router.dart';
import 'package:querya_desktop/core/erd/erd_router.dart';

/// Colours of the SVG export as `#rrggbb`. The defaults are a light theme;
/// the diagram view passes the colours of the current theme, so the file looks
Expand Down
102 changes: 102 additions & 0 deletions lib/features/erd/erd_export_data.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart' as material;
import 'package:querya_desktop/core/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';
import 'package:querya_desktop/core/erd/erd_saved_layout.dart';
import 'package:querya_desktop/core/theme/querya_theme_scope.dart';
import 'package:querya_desktop/features/erd/erd_export.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn;

/// Turns what the diagram shows on screen (header tints, notes, group frames,
/// theme colours) into the plain values the SVG / DBML export takes (#1362).
///
/// Everything is a function of its arguments: the view state passes its data
/// and the theme lookups in, nothing here reads widget state.
abstract final class ErdExportData {
static material.Color _fromHex(String hex) =>
material.Color(0xFF000000 | int.parse(hex.substring(1), radix: 16));

/// Header bands for the SVG: the screen's tint over the export's card colour.
static Map<String, String> headerFills(
ErdSchema schema,
String cardHex, {
required Map<String, String> headerColors,
required material.Color Function(String table) headerColor,
}) {
final card = _fromHex(cardHex);
return {
for (final t in schema.tables)
if (erdHeaderSlot(t.name, headerColors) != null)
t.name: ErdSvgColors.hex(material.Color.alphaBlend(
headerColor(t.name).withValues(alpha: 0.14), card)
.toARGB32()),
};
}

/// Notes for the SVG, tinted as on screen over the export background.
static List<ErdSvgNote> notes(
List<ErdNote> notes,
ErdSvgColors colors, {
required material.Color Function(String slot) slotColor,
required double noteTint,
}) {
final background = _fromHex(colors.background);
final muted = _fromHex(colors.muted);
return [
for (final n in notes)
ErdSvgNote(
text: n.text,
rect: material.Rect.fromLTWH(n.x, n.y, n.width, n.height),
fill: ErdSvgColors.hex(material.Color.alphaBlend(
(n.color == null ? muted : slotColor(n.color!))
.withValues(alpha: n.color == null ? noteTint : 0.18),
background)
.toARGB32()),
stroke: n.color == null
? colors.border
: ErdSvgColors.hex(slotColor(n.color!).toARGB32()),
),
];
}

/// Group frames for the SVG: the screen's tint over the export background.
static List<ErdSvgGroup> groups(
List<ErdGroup> groups,
ErdLayout layout,
String backgroundHex, {
required material.Color Function(String slot) slotColor,
}) {
final background = _fromHex(backgroundHex);
return [
for (final g in groups)
if (layout.frameOf(g.tables) case final frame?)
ErdSvgGroup(
name: g.name,
frame: frame,
fill: ErdSvgColors.hex(material.Color.alphaBlend(
slotColor(g.color).withValues(alpha: 0.06), background)
.toARGB32()),
stroke: ErdSvgColors.hex(slotColor(g.color).toARGB32()),
),
];
}

/// Colours of the current theme for the SVG export, as the screen draws the
/// cards and edges.
static ErdSvgColors themeColors(material.BuildContext context) {
final wb = context.workbench;
final palette = context.semanticPalette;
String hex(material.Color c) => ErdSvgColors.hex(c.toARGB32());
return ErdSvgColors(
background: hex(wb.surface),
card: hex(wb.surface),
border: hex(wb.borderSubtle),
header: hex(material.Color.alphaBlend(
wb.accent.withValues(alpha: 0.10), wb.surface)),
text: hex(shadcn.Theme.of(context).colorScheme.foreground),
muted: hex(wb.mutedForeground),
edge: hex(wb.mutedForeground),
primaryKey: hex(palette.type1),
foreignKey: hex(palette.type2),
);
}
}
Loading
Loading