Pixelgenau migriert: Clipper-Masken direkt zu HTML – kein Netz, kein doppelter Boden

Präprozessor statt Runtime-Wrapping – mein Experiment und warum es Sinn macht
Kurzfassung: Anstatt FiveWin-Style Code zur Laufzeit zu „wrappen“, lasse ich ihn zur Build-Zeit durch einen Präprozessor + Patcher laufen (HARBOURNO-Style). Ergebnis sind fertige, deterministische HTML/JS-Artefakte – ohne Runtime-Parser. In der Praxis fühlt sich das stabiler, schneller und deutlich wartbarer an.
Warum Preprocess statt Runtime?
Speed & Footprint: Kein Laufzeit-Parser, nur statisches PHP/HTML/JS.
Deterministische Artefakte: Leicht zu diffen, cachen, minifizieren, signieren, deployen.
Bessere Fehlerbilder: „Zeile 42: unbekanntes Token ADD oCol“ statt schwammiger Runtime-Fehler.
Sichere Refactors: Renderer tauschen (jQuery → Vanilla/WH-UI) ohne den PRG-Quelltext zu ändern.
Source-Maps/Breadcrumbs: //-- bc 0012 bleibt erhalten; im HTML landen <!-- src: file.prg:123 -->.
Multi-Target Build: Ein Source → mehrere Outputs (Bootstrap+jQuery heute, WH-UI/vanilla morgen).
Idee in einem Satz
FiveWin-/Clipper-artige UI-Syntax bleibt, aber wird zur Build-Zeit in ein neutrales UI-AST übersetzt und dann von Renderern in HTML/JS umgesetzt. Patch-Blöcke (-> BLOCK, $-> BLOCK) laufen als Compile-Time-Transform.
PRG (@ SAY/GET/BUTTON, DEFINE BROWSE …)
→ Lexer/Parser → AST → Patcher
→ Renderer A (Bootstrap/jQuery) → index.html
→ Renderer B (WH-UI/Vanilla) → index_wh.html
Mini-Beispiel
Input (PRG, „klassisch“):
DEFINE DIALOG oDlg TITLE "Harbourino Demo"
@ 1, 2 SAY "Text..:"
@ 1, 6 GET oGet VAR cCad SIZE 60,10 PICTURE "@K"
@ 3, 7 BUTTON "&Ok" ACTION oDlg:End()
Output (gekürzt):
<!-- src: demo.prg:2 -->
<label class="item" style="left:calc(var(--ux)*2); top:calc(var(--uy)*1);">Text..:</label>
<!-- src: demo.prg:3 -->
<input class="item"
type="text" name="cCad" id="cCad"
style="left:calc(var(--ux)*6); top:calc(var(--uy)*1); width:calc(var(--ux)*60); height:calc(var(--uy)*10);" />
<!-- src: demo.prg:4 -->
<button class="item" type="submit" accesskey="o"
onclick="document.getElementById('frm')?.requestSubmit?.()">Ok</button>
BROWSE-Mapping (OOP-Style → deklarativ):
DEFINE BROWSE oBrw ID 'ringo' HEIGHT 400 OF oDlg
ADD oCol TO oBrw ID 'id' HEADER 'Id.' ALIGN 'center'
ADD oCol TO oBrw ID 'name' HEADER 'Name'
INIT BROWSE oBrw DATA aRows
→ erzeugt <table id="ringo"> + columns-Definition und ein bootstrapTable({ data, columns }); Renderer-Switch auf eine Vanilla-Grid-Komponente ist später nur ein Build-Flag.
Was der Prototyp heute kann
Prozedural: @ SAY/GET/BUTTON, plus CHECKBOX, RADIO, LISTBOX.
OOP-artig: DEFINE BROWSE/ADD/INIT, HTML … ENDTEXT (inline JS/HTML).
Layout-Modi:
Absolut (Row/Col → CSS-Einheiten, pixelgenau für Migration).
Grid (--grid[=24]), gleiche Syntax, aber responsive Spans.
Defaults aus LOCAL … := … werden als Startwerte übernommen.
Breadcrumbs & „src: file:line“ landen in den Artefakten.
Warum das in der Praxis hilft
Team-Workflow: PRG bleibt die Authoring-Quelle. Der Web-Output ist „dumm“ und stabil – perfekt für Reviews/Commits/CDN-Cache.
Schrittweise Modernisierung: Erst Bootstrap/jQuery, später WH-UI/Vanilla – ohne die Oberfläche neu zu modellieren.
Fehlersuche: Compiler meckert genau da, wo die DSL inkonsistent ist. Kein Debuggen quer durch Wrapper-Magie.
Grenzen (ehrlich)
Der Parser ist bewusst pragmatisch (Regex-getrieben). Für 90 % der Masken reicht’s; exotische Fälle brauchen Grammatik/AST-Ausbau.
Formatter/Masks sind basic; echte Masken (z. B. IBAN/Telefon) wären ein optionales Client-Plugin.
Komplexe Container/Repeater: besser deklarativ oder als Compile-Time-Komponenten (Template-Klassen), nicht als @-Zeilen.
Roadmap (was sich anbietet)
Compile-Time-Templates für Komponenten (z. B. TWebButton:Activate() → <button>), austauschbar je Renderer.
Validierung & Bindings: kleine Declarative-Rules, die der Renderer automatisch verdrahtet.
Strenger Modus: Unbekannte Keywords → harter Fehler inkl. „Did you mean …?“.
So kann man’s ausprobieren
php hw_proc.php demo_proc.prg > demo_abs.html # klassisch, absolut
php hw_proc.php demo_proc.prg --grid > demo_grid.html # responsive Grid
demo_proc.prg enthält SAY/GET/DATE/Buttons, RADIO, CHECKBOX, LISTBOX; die Titel/Defaults kommen aus den LOCAL-Zeilen.
Fazit: Der HARBOURNO-Präprozessor fühlt sich überraschend „richtig“ an: Dasselbe Autorenerlebnis wie früher, aber Build-Zeit-Artefakte wie im modernen Web. Für Migrationen und nachhaltige UI-Modernisierung ist das – aus meiner Sicht – der stabilere Weg als Runtime-Wrapping.
LG
Otto
demo_proc.prg
Hw Proc.php V0.3· php

Präprozessor statt Runtime-Wrapping – mein Experiment und warum es Sinn macht
Kurzfassung: Anstatt FiveWin-Style Code zur Laufzeit zu „wrappen“, lasse ich ihn zur Build-Zeit durch einen Präprozessor + Patcher laufen (HARBOURNO-Style). Ergebnis sind fertige, deterministische HTML/JS-Artefakte – ohne Runtime-Parser. In der Praxis fühlt sich das stabiler, schneller und deutlich wartbarer an.
Warum Preprocess statt Runtime?
Speed & Footprint: Kein Laufzeit-Parser, nur statisches PHP/HTML/JS.
Deterministische Artefakte: Leicht zu diffen, cachen, minifizieren, signieren, deployen.
Bessere Fehlerbilder: „Zeile 42: unbekanntes Token ADD oCol“ statt schwammiger Runtime-Fehler.
Sichere Refactors: Renderer tauschen (jQuery → Vanilla/WH-UI) ohne den PRG-Quelltext zu ändern.
Source-Maps/Breadcrumbs: //-- bc 0012 bleibt erhalten; im HTML landen <!-- src: file.prg:123 -->.
Multi-Target Build: Ein Source → mehrere Outputs (Bootstrap+jQuery heute, WH-UI/vanilla morgen).
Idee in einem Satz
FiveWin-/Clipper-artige UI-Syntax bleibt, aber wird zur Build-Zeit in ein neutrales UI-AST übersetzt und dann von Renderern in HTML/JS umgesetzt. Patch-Blöcke (-> BLOCK, $-> BLOCK) laufen als Compile-Time-Transform.
PRG (@ SAY/GET/BUTTON, DEFINE BROWSE …)
→ Lexer/Parser → AST → Patcher
→ Renderer A (Bootstrap/jQuery) → index.html
→ Renderer B (WH-UI/Vanilla) → index_wh.html
Mini-Beispiel
Input (PRG, „klassisch“):
DEFINE DIALOG oDlg TITLE "Harbourino Demo"
@ 1, 2 SAY "Text..:"
@ 1, 6 GET oGet VAR cCad SIZE 60,10 PICTURE "@K"
@ 3, 7 BUTTON "&Ok" ACTION oDlg:End()
Output (gekürzt):
<!-- src: demo.prg:2 -->
<label class="item" style="left:calc(var(--ux)*2); top:calc(var(--uy)*1);">Text..:</label>
<!-- src: demo.prg:3 -->
<input class="item"
type="text" name="cCad" id="cCad"
style="left:calc(var(--ux)*6); top:calc(var(--uy)*1); width:calc(var(--ux)*60); height:calc(var(--uy)*10);" />
<!-- src: demo.prg:4 -->
<button class="item" type="submit" accesskey="o"
onclick="document.getElementById('frm')?.requestSubmit?.()">Ok</button>
BROWSE-Mapping (OOP-Style → deklarativ):
DEFINE BROWSE oBrw ID 'ringo' HEIGHT 400 OF oDlg
ADD oCol TO oBrw ID 'id' HEADER 'Id.' ALIGN 'center'
ADD oCol TO oBrw ID 'name' HEADER 'Name'
INIT BROWSE oBrw DATA aRows
→ erzeugt <table id="ringo"> + columns-Definition und ein bootstrapTable({ data, columns }); Renderer-Switch auf eine Vanilla-Grid-Komponente ist später nur ein Build-Flag.
Was der Prototyp heute kann
Prozedural: @ SAY/GET/BUTTON, plus CHECKBOX, RADIO, LISTBOX.
OOP-artig: DEFINE BROWSE/ADD/INIT, HTML … ENDTEXT (inline JS/HTML).
Layout-Modi:
Absolut (Row/Col → CSS-Einheiten, pixelgenau für Migration).
Grid (--grid[=24]), gleiche Syntax, aber responsive Spans.
Defaults aus LOCAL … := … werden als Startwerte übernommen.
Breadcrumbs & „src: file:line“ landen in den Artefakten.
Warum das in der Praxis hilft
Team-Workflow: PRG bleibt die Authoring-Quelle. Der Web-Output ist „dumm“ und stabil – perfekt für Reviews/Commits/CDN-Cache.
Schrittweise Modernisierung: Erst Bootstrap/jQuery, später WH-UI/Vanilla – ohne die Oberfläche neu zu modellieren.
Fehlersuche: Compiler meckert genau da, wo die DSL inkonsistent ist. Kein Debuggen quer durch Wrapper-Magie.
Grenzen (ehrlich)
Der Parser ist bewusst pragmatisch (Regex-getrieben). Für 90 % der Masken reicht’s; exotische Fälle brauchen Grammatik/AST-Ausbau.
Formatter/Masks sind basic; echte Masken (z. B. IBAN/Telefon) wären ein optionales Client-Plugin.
Komplexe Container/Repeater: besser deklarativ oder als Compile-Time-Komponenten (Template-Klassen), nicht als @-Zeilen.
Roadmap (was sich anbietet)
Compile-Time-Templates für Komponenten (z. B. TWebButton:Activate() → <button>), austauschbar je Renderer.
Validierung & Bindings: kleine Declarative-Rules, die der Renderer automatisch verdrahtet.
Strenger Modus: Unbekannte Keywords → harter Fehler inkl. „Did you mean …?“.
So kann man’s ausprobieren
php hw_proc.php demo_proc.prg > demo_abs.html # klassisch, absolut
php hw_proc.php demo_proc.prg --grid > demo_grid.html # responsive Grid
demo_proc.prg enthält SAY/GET/DATE/Buttons, RADIO, CHECKBOX, LISTBOX; die Titel/Defaults kommen aus den LOCAL-Zeilen.
Fazit: Der HARBOURNO-Präprozessor fühlt sich überraschend „richtig“ an: Dasselbe Autorenerlebnis wie früher, aber Build-Zeit-Artefakte wie im modernen Web. Für Migrationen und nachhaltige UI-Modernisierung ist das – aus meiner Sicht – der stabilere Weg als Runtime-Wrapping.
LG
Otto
demo_proc.prg
// Testing GETs
#include "FiveWin.ch"
function Main()
LOCAL oDlg, oGet
LOCAL cCad := "Testing " // pad("Testing Gets",40)
LOCAL nNum := 0
LOCAL dDat := Date()
Set century On
Set Date Ansi
Set Date format "mm/dd/yyyy"
SET _3DLOOK ON
DEFINE DIALOG oDlg TITLE "TGet from " + FWDESCRIPTION
@ 1, 2 SAY "Text..:" OF oDlg
@ 1, 6 GET oGet VAR cCad OF oDlg SIZE 60, 10 COLOR "W/G" PICTURE "@K"
@ 1.8, 2 SAY "Number:" OF oDlg
@ 2, 6 GET oGet VAR nNum OF oDlg SIZE 60, 10 PICTURE "9999999.99"
@ 2.6, 2 SAY "Date:" OF oDlg
@ 3, 6 GET oGet VAR dDat PICTURE "@E" OF oDlg SIZE 60, 10 // "@D"
@ 3, 7 BUTTON "&Ok" OF oDlg SIZE 30, 12 ACTION oDlg:End()
@ 3, 16 BUTTON "&Cancel" SIZE 30, 12 OF oDlg ACTION oDlg:End() CANCEL
// TGet():SetColorFocus( nRGB( 200, 120, 120 ) )
ACTIVATE DIALOG oDlg CENTERED
return nil
//------------------------------------------------------------------------//
procedure appsys // XBase++ requirement
return <?php
/**
* Harbourino Procedural @ Parser – v0.3.4 (grid collision solver)
* ---------------------------------------------------------------
* - Grid mode: maps SIZE w (ch) → grid-column span AND prevents overlaps
* via per-row interval packing (nudge right / wrap to next row if needed).
* - Absolute mode: keeps auto-height + overlap-guard from 0.3.2.
* - Inputs/select width 100% in grid cell; buttons keep natural width
* (justify-self:start) and get a small default span.
*
* Usage:
* php hw_proc.php demo_proc.prg > demo_abs.html
* php hw_proc.php demo_proc.prg --grid > demo_grid.html
* php hw_proc.php demo_proc.prg --grid=16 --ch-per-col=5 > demo_grid16.html
*/
ini_set('display_errors', 1);
error_reporting(E_ALL);
// --- CLI args ---------------------------------------------------------------
$in = $argv[1] ?? null;
if (!$in || !is_file($in)) {
fwrite(STDERR, "Usage: php hw_proc.php input.prg [--grid[=24]] [--grid-cols=24] [--ch-per-col=4]\n");
exit(1);
}
list($grid, $gridCols, $chPerCol) = parse_cli_flags($argv);
$src = file_get_contents($in);
$src = str_replace(["\r\n", "\r"], "\n", $src);
// Strip includes / templating remnants
$src = preg_replace('/#include\s+\"[^\"]*\"/i', '', $src);
$src = preg_replace('/\{\%.*?\%\}/s', '', $src);
// Title from DEFINE DIALOG ... TITLE
$title = 'Harbourino Classic Form';
if (preg_match('/DEFINE\s+DIALOG\s+\w+\s+TITLE\s+(\"([^\"]*)\"|\'([^\']*)\')/i', $src, $m)) {
$title = $m[2] !== '' ? $m[2] : $m[3];
}
// Defaults from LOCAL
$varDefaults = parse_locals($src);
// Parse @ lines
$items = parse_items($src);
// --- Placement --------------------------------------------------------------
if ($grid) {
$placed = layout_grid($items, $gridCols, $chPerCol);
} else {
$placed = layout_absolute($items); // keeps 0.3.2 nudge behaviour
}
// --- Emit HTML --------------------------------------------------------------
$out = [];
$out[] = '<!doctype html>';
$out[] = '<html lang="en">';
$out[] = '<head>';
$out[] = ' <meta charset="utf-8">';
$out[] = ' <meta name="viewport" content="width=device-width, initial-scale=1">';
$out[] = ' <title>' . h($title) . '</title>';
$out[] = ' <style>';
$out[] = ' :root{ --ux:8px; --uy:20px; --font:14px/1.35 system-ui,Segoe UI,Roboto,Arial; --cols:' . (int)$gridCols . '; --input-h:36px }';
$out[] = ' *{ box-sizing:border-box } body{margin:0; font: var(--font); background:#0e1116; color:#e9eef5 }';
$out[] = ' .wrap{ ' . ($grid ? 'display:grid; grid-template-columns:repeat(var(--cols),minmax(0,1fr)); grid-auto-rows:minmax(36px,auto); gap:8px; align-items:center; padding:16px;' : 'position:relative; min-height:320px; padding:16px;') . ' }';
$out[] = ' .item{ ' . ($grid ? 'position:relative;' : 'position:absolute;') . ' }';
$out[] = ' label{ font-weight:600 }';
$out[] = ' input,select,button{ font: inherit }';
$out[] = ' input[type=text],input[type=number],input[type=date]{ background:#151a22; border:1px solid #2a3240; color:#e9eef5; padding:6px 8px; border-radius:8px; height:var(--input-h); line-height:var(--input-h); }';
if ($grid) { $out[] = ' input[type=text],input[type=number],input[type=date],select{ width:100%; }'; }
$out[] = ' select{ background:#151a22; border:1px solid #2a3240; color:#e9eef5; padding:6px 8px; border-radius:8px; }';
$out[] = ' select[multiple]{ min-height:120px }';
$out[] = ' button{ background:#2a7de1; color:white; border:0; padding:6px 12px; border-radius:8px; cursor:pointer; min-height:var(--input-h); justify-self:start }';
$out[] = ' button.secondary{ background:#3a3f46 }';
$out[] = ' .checkbox{ display:inline-flex; align-items:center; gap:8px }';
$out[] = ' .radio-group{ display:flex; flex-direction:column; gap:6px }';
$out[] = ' .radio-group.horizontal{ flex-direction:row; flex-wrap:wrap }';
$out[] = ' .radio-group label{ font-weight:400 }';
$out[] = ' </style>';
$out[] = '</head>';
$out[] = '<body>';
$out[] = '<form id="frm"><div class="wrap">';
foreach ($placed as $it) {
switch ($it['type']) {
case 'say':
$style = style_for($it, $grid, $gridCols, $chPerCol);
$out[] = '<label class="item" style="' . $style . '">' . h($it['text']) . '</label>';
break;
case 'get':
$val = value_for($it['var'], $varDefaults);
list($type, $extra) = input_type_and_mask($it['var'], $it['picture']);
$style = style_for($it, $grid, $gridCols, $chPerCol);
if (!$grid && !empty($it['w'])) { $style .= ' width:' . (int)$it['w'] . 'ch;'; }
$colorCss = color_css($it['color']);
$attr = '';
if ($type === 'number') $attr .= ' inputmode="decimal"';
if (!empty($extra['step'])) $attr .= ' step="' . $extra['step'] . '"';
$valueAttr = $val !== null ? ' value="' . h($val) . '"' : '';
$out[] = '<input class="item" style="' . $style . $colorCss . '" type="' . $type . '" name="' . h($it['var']) . '" id="' . h($it['var']) . '"' . $attr . $valueAttr . ' />';
break;
case 'checkbox':
$style = style_for($it, $grid, $gridCols, $chPerCol);
$checked = truthy(value_for($it['var'], $varDefaults)) ? ' checked' : '';
$label = h($it['prompt'] ?? '');
$name = h($it['var']);
$out[] = '<label class="item checkbox" style="' . $style . '"><input type="checkbox" name="' . $name . '" id="' . $name . '"' . $checked . '>' . $label . '</label>';
break;
case 'radio':
$style = style_for($it, $grid, $gridCols, $chPerCol);
$horizontal = !empty($it['horizontal']);
$name = h($it['var']);
$default = value_for($it['var'], $varDefaults);
$cls = $horizontal ? 'radio-group horizontal' : 'radio-group';
$html = '<div class="item ' . $cls . '" style="' . $style . '">';
foreach ($it['items'] as $idx => $text) {
$val = (string)($idx + 1);
$id = $name . '_' . $val;
$sel = '';
if (is_numeric($default) && (int)$default === ($idx + 1)) $sel = ' checked';
elseif (is_string($default) && strcasecmp($default, $text) === 0) $sel = ' checked';
$html .= '<label><input type="radio" name="' . $name . '" id="' . h($id) . '" value="' . h($val) . '"' . $sel . '> ' . h($text) . '</label>';
}
$html .= '</div>';
$out[] = $html;
break;
case 'listbox':
$style = style_for($it, $grid, $gridCols, $chPerCol);
if (!$grid && !empty($it['w'])) { $style .= ' width:' . (int)$it['w'] . 'ch;'; }
$name = h($it['var']);
$multi = !empty($it['multi']);
$default = value_for($it['var'], $varDefaults);
$sizeAttr = ($it['h'] && $it['h'] > 1) ? ' size="' . (int)$it['h'] . '"' : '';
$multiAttr = $multi ? ' multiple' : '';
$html = '<select class="item" style="' . $style . '" name="' . $name . '" id="' . $name . '"' . $sizeAttr . $multiAttr . '>';
foreach ($it['items'] as $idx => $text) {
$isSel = false;
if (is_numeric($default) && (int)$default === ($idx + 1)) $isSel = true;
elseif (is_string($default) && strcasecmp($default, $text) === 0) $isSel = true;
$html .= '<option value="' . h($text) . '"' . ($isSel ? ' selected' : '') . '>' . h($text) . '</option>';
}
$html .= '</select>';
$out[] = $html;
break;
case 'button':
list($label, $access) = accesskey_from_amp($it['label']);
$style = style_for($it, $grid, $gridCols, $chPerCol);
$typeBtn = $it['cancel'] ? 'button' : 'submit';
$cls = $it['cancel'] ? 'secondary' : '';
$ak = $access ? ' accesskey="' . h(strtolower($access)) . '"' : '';
$onclick = '';
if ($it['cancel']) { $onclick = ' onclick="history.back();"'; }
elseif (!empty($it['action'])) { $onclick = ' onclick="' . h(js_action($it['action'])) . '"'; $typeBtn = 'button'; }
$out[] = '<button class="item ' . $cls . '" style="' . $style . '" type="' . $typeBtn . '"' . $ak . $onclick . '>' . h($label) . '</button>';
break;
}
}
$out[] = '</div></form>';
$out[] = '<script>document.getElementById("frm")?.addEventListener("submit", function(e){ e.preventDefault(); alert("Submitted!"); });</script>';
$out[] = '</body>';
$out[] = '</html>';
echo implode("\n", $out);
// =============================== Layout =====================================
function layout_grid(array $items, int $cols, int $chPerCol): array {
// Sort by row, then col
usort($items, function($a,$b){
if ($a['row'] == $b['row']) return $a['col'] <=> $b['col'];
return $a['row'] <=> $b['row'];
});
$ranges = []; // rowKey => [ [start,end], ... ]
$placed = [];
foreach ($items as $it) {
$r = (int)round($it['row']);
$start = max(1, (int)round($it['col']));
$span = grid_span_cols(default_width_ch_for_type($it['type'], $it), $cols, $chPerCol);
if ($it['type'] === 'button') $span = max(2, min($span, 4)); // small default span for buttons
// SAY does not occupy span
if ($it['type'] !== 'say') {
while (true) {
$conflict = conflict_in_row($ranges[$r] ?? [], $start, $start + $span - 1);
if (!$conflict) break;
$start = $conflict[1] + 1; // move right after the conflicting block
if ($start + $span - 1 > $cols) { // wrap
$r += 1; $start = 1; // next row, from column 1
}
}
// register interval
if (!isset($ranges[$r])) $ranges[$r] = [];
$ranges[$r][] = [$start, $start + $span - 1];
}
$it['render_row'] = $r;
$it['render_col'] = $start;
$it['grid_span'] = $span;
$placed[] = $it;
}
return $placed;
}
function conflict_in_row(array $intervals, int $start, int $end) {
foreach ($intervals as $iv) {
if (!($end < $iv[0] || $start > $iv[1])) return $iv; // overlap
}
return null;
}
function layout_absolute(array $items): array {
// Keep simple same-row nudge as 0.3.2
$rowRanges = []; $MARGIN = 2; $placed = [];
foreach ($items as $it) {
$it['render_col'] = $it['col'];
$rowKey = row_key($it['row']);
$wCols = approx_width_cols($it);
if ($wCols > 0) {
if (!isset($rowRanges[$rowKey])) $rowRanges[$rowKey] = [];
$newCol = $it['render_col'];
foreach ($rowRanges[$rowKey] as $span) {
if ($newCol >= $span[0] && $newCol < $span[1]) { $newCol = $span[1] + $MARGIN; }
}
$it['render_col'] = $newCol;
$rowRanges[$rowKey][] = [ $it['render_col'], $it['render_col'] + $wCols ];
}
$placed[] = $it;
}
return $placed;
}
// =============================== Parsers ====================================
function parse_items(string $src): array {
$items = [];
$lines = explode("\n", $src);
foreach ($lines as $line) {
if (!preg_match('/^\s*\@/', $line)) continue;
$L = $line;
if (!preg_match('/\@\s*([0-9]+(?:\.[0-9]+)?)\s*,\s*([0-9]+(?:\.[0-9]+)?)/', $L, $rc)) continue;
$row = (float)$rc[1]; $col = (float)$rc[2];
if (preg_match('/SAY\s+(\"([^\"]*)\"|\'([^\']*)\')/i', $L, $m)) {
$text = $m[2] !== '' ? $m[2] : $m[3];
$items[] = ['type'=>'say','row'=>$row,'col'=>$col,'text'=>$text];
continue;
}
if (preg_match('/GET\s+\w+\s+VAR\s+(\w+)(.*)$/i', $L, $m)) {
$var=$m[1]; $rest=$m[2]; list($w,$h)=parse_size($rest); $color=parse_color($rest); $picture=parse_picture($rest);
$items[] = ['type'=>'get','row'=>$row,'col'=>$col,'var'=>$var,'w'=>$w,'h'=>$h,'color'=>$color,'picture'=>$picture];
continue;
}
if (preg_match('/CHECKBOX\s+VAR\s+(\w+)(.*)$/i', $L, $m)) {
$var=$m[1]; $rest=$m[2]; list($w,$h)=parse_size($rest); $prompt=parse_prompt($rest);
$items[] = ['type'=>'checkbox','row'=>$row,'col'=>$col,'var'=>$var,'w'=>$w,'h'=>$h,'prompt'=>$prompt];
continue;
}
if (preg_match('/RADIO\s+VAR\s+(\w+)(.*)$/i', $L, $m)) {
$var=$m[1]; $rest=$m[2]; list($w,$h)=parse_size($rest); $itemsList=parse_items_list($rest); $horizontal=(bool)preg_match('/\bHORIZONTAL\b/i',$rest);
$items[] = ['type'=>'radio','row'=>$row,'col'=>$col,'var'=>$var,'w'=>$w,'h'=>$h,'items'=>$itemsList,'horizontal'=>$horizontal];
continue;
}
if (preg_match('/LISTBOX\s+VAR\s+(\w+)(.*)$/i', $L, $m)) {
$var=$m[1]; $rest=$m[2]; list($w,$h)=parse_size($rest); $itemsList=parse_items_list($rest); $multi=(bool)preg_match('/\bMULTI\b/i',$rest);
$items[] = ['type'=>'listbox','row'=>$row,'col'=>$col,'var'=>$var,'w'=>$w,'h'=>$h,'items'=>$itemsList,'multi'=>$multi];
continue;
}
if (preg_match('/BUTTON\s+(\"([^\"]*)\"|\'([^\']*)\')(.*)$/i', $L, $m)) {
$label=$m[2] !== '' ? $m[2] : $m[3]; $rest=$m[4]; list($w,$h)=parse_size($rest); $action=parse_action($rest); $cancel=(bool)preg_match('/\bCANCEL\b/i',$rest);
$items[] = ['type'=>'button','row'=>$row,'col'=>$col,'label'=>$label,'w'=>$w,'h'=>$h,'action'=>$action,'cancel'=>$cancel];
continue;
}
}
return $items;
}
function parse_locals(string $src): array {
$defs = [];
if (preg_match_all('/LOCAL\s+([^\n]+)/i', $src, $ml, PREG_SET_ORDER)) {
foreach ($ml as $line) {
$chunk = $line[1]; $parts = preg_split('/\s*,\s*/', $chunk);
foreach ($parts as $p) {
$p = trim($p); if ($p==='') continue;
if (strpos($p,':=')!==false){ [$name,$val] = explode(':=',$p,2); $name = preg_replace('/\s+.*/','',trim($name)); $defs[$name] = parse_literal(trim($val)); }
}
}
}
return $defs;
}
function parse_size(string $rest){ $w=null; $h=null; if (preg_match('/SIZE\s+(\d+)\s*,\s*(\d+)/i',$rest,$mm)){ $w=(int)$mm[1]; $h=(int)$mm[2]; } return [$w,$h]; }
function parse_color(string $rest){ if (preg_match('/COLOR\s+(\"([^\"]*)\"|\'([^\']*)\')/i',$rest,$mm)) return $mm[2]!==''?$mm[2]:$mm[3]; return null; }
function parse_picture(string $rest){ if (preg_match('/PICTURE\s+(\"([^\"]*)\"|\'([^\']*)\')/i',$rest,$mm)) return $mm[2]!==''?$mm[2]:$mm[3]; return null; }
function parse_prompt(string $rest){ if (preg_match('/PROMPT\s+(\"([^\"]*)\"|\'([^\']*)\')/i',$rest,$mm)) return $mm[2]!==''?$mm[2]:$mm[3]; return null; }
function parse_items_list(string $rest){ $items=[]; if (preg_match('/ITEMS\s+(.*)$/i',$rest,$m)){ $tail=$m[1]; if (preg_match_all('/\"([^\"]*)\"|\'([^\']*)\'/',$tail,$mm,PREG_SET_ORDER)){ foreach($mm as $q){ $items[] = $q[1]!==''?$q[1]:$q[2]; } } } return $items; }
function parse_action(string $rest){ if (preg_match('/ACTION\s+([^\s].*?)(?:\s+CANCEL|$)/i',$rest,$mm)) return trim($mm[1]); return null; }
// =============================== Helpers ====================================
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES); }
function truthy($v){ return $v===true || $v===1 || $v==='1' || (is_string($v) && preg_match('/^(true|.T.)$/i',$v)); }
function row_key($row){ return sprintf('%.1f', round($row,1)); }
function approx_width_cols($it){
switch ($it['type']){
case 'get': return $it['w'] ?? 24;
case 'listbox': return $it['w'] ?? 20;
case 'radio': return max(12, (isset($it['items'])?count($it['items']):1)*8);
case 'checkbox': return 12;
case 'button': return 8;
default: return 0;
}
}
function grid_span_cols($wCh, $cols, $chPerCol=4){ if ($wCh===null) $wCh=24; $span=(int)ceil($wCh/max(1,(int)$chPerCol)); return max(1, min($span,(int)$cols)); }
function default_width_ch_for_type($type,$it){
switch ($type){
case 'get': return $it['w'] ?? 24;
case 'listbox': return $it['w'] ?? 20;
case 'radio': return max(12,(isset($it['items'])?count($it['items']):1)*8);
case 'checkbox': return 12;
case 'button': return 10;
default: return 12;
}
}
function style_for($it, $grid, $cols, $chPerCol){
if ($grid){
$cStart = max(1,(int)round($it['render_col']));
$rStart = max(1,(int)round($it['render_row'] ?? $it['row']));
$span = $it['grid_span'] ?? grid_span_cols(default_width_ch_for_type($it['type'],$it), $cols, $chPerCol);
// listbox taller rows
$rSpan = ($it['type']==='listbox' && !empty($it['h']) && $it['h']>1) ? max(1,(int)round($it['h']/2)) : 1;
return 'grid-column: '.$cStart.' / span '.$span.'; grid-row: '.$rStart.' / span '.$rSpan.';';
}
// absolute
return 'left:calc(var(--ux)*'.round($it['render_col'] ?? $it['col'],2).'); top:calc(var(--uy)*'.round($it['row'],2).');';
}
function parse_literal($v){ $v=trim($v); $v=preg_replace('/\s*\/\/.*$/','',$v); if ($v==='Date()') return date('Y-m-d'); if ($v==='.T.'||strtoupper($v)==='TRUE') return true; if ($v==='.F.'||strtoupper($v)==='FALSE') return false; if ($v!=='' && ($v[0]=='"' || $v[0]=="'")) return trim($v,"'\""); if (is_numeric($v)) return 0+$v; return null; }
function value_for($name,$defs){ if(!array_key_exists($name,$defs)) return null; $v=$defs[$name]; if($v instanceof DateTimeInterface) return $v->format('Y-m-d'); if (preg_match('/^d[A-Z0-9_]*$/',$name) && is_string($v)) { $t=strtotime($v); if($t) return date('Y-m-d',$t);} if(is_bool($v)) return $v?'1':'0'; return $v; }
function input_type_and_mask($var,$picture){ $var=(string)$var; $pic=(string)$picture; $type='text'; $extra=[]; if($pic && preg_match('/9/',$pic)){ $type='number'; if (strpos($pic,'.')!==false) $extra['step']='0.01'; } elseif($pic && preg_match('/@E|@D/i',$pic)){ $type='date'; } elseif(preg_match('/^n[A-Z0-9_]*$/',$var)){ $type='number'; } elseif(preg_match('/^d[A-Z0-9_]*$/',$var)){ $type='date'; } return [$type,$extra]; }
function color_css($c){ if(!$c) return ''; $parts=explode('/',$c); $fg=strtoupper(trim($parts[0]??'')); $bg=strtoupper(trim($parts[1]??'')); $map=['W'=>'#ffffff','B'=>'#000000','G'=>'#2e7d32','R'=>'#c62828','Y'=>'#fdd835']; $fgc=$map[$fg]??null; $bgc=$map[$bg]??null; $css=''; if($bgc) $css.=' background:'.$bgc.';'; if($fgc) $css.=' color:'.$fgc.';'; return $css; }
function accesskey_from_amp($label){ $pos=strpos($label,'&'); if($pos===false || $pos===strlen($label)-1) return [str_replace('&','',$label),null]; $key=substr($label,$pos+1,1); $clean=substr($label,0,$pos).$key.substr($label,$pos+2); return [$clean,$key]; }
function js_action($s){ if(preg_match('/\bEnd\s*\(\s*\)/i',$s)) return 'document.getElementById(\'frm\')?.requestSubmit?.()'; return $s; }
function parse_cli_flags($argv){ $grid=false; $cols=24; $ch=4; foreach($argv as $arg){ if($arg==='--grid'){ $grid=true; } elseif(strpos($arg,'--grid=')===0){ $grid=true; $val=(int)substr($arg,7); if($val>0) $cols=$val; } elseif(strpos($arg,'--grid-cols=')===0){ $val=(int)substr($arg,13); if($val>0) $cols=$val; } elseif(strpos($arg,'--ch-per-col=')===0){ $val=(int)substr($arg,13); if($val>0) $ch=$val; } } return [$grid,$cols,$ch]; }