1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-17 23:13:03 +08:00

refactor(gui): 编排窗口简化为纯代码生成器,移除脚本解析与预检逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 04:15:21 +08:00
parent fe7453fe02
commit e097b5afc9
5 changed files with 116 additions and 643 deletions
+2 -140
View File
@@ -13,27 +13,15 @@ from PySide6.QtWidgets import (
QWidget,
)
from gui.ALAutoScriptOrchDialog._precheck import precheck
from gui.ALAutoScriptOrchDialog._orchestrate import parseBlocks
from gui.ALAutoScriptOrchDialog._helpers import (
COMPARE_OPERATORS,
PRESET_NAMES,
VariableManager,
findOperatorIn,
splitTopLevel,
stripOuterParens,
)
from gui.ALAutoScriptOrchDialog._helpers import VariableManager
from gui.ALAutoScriptOrchDialog._blocks import ConditionalBlock
from gui.ALAutoScriptOrchDialog._widgets import ConditionRowFrame
class ALAutoScriptOrchDialog(QDialog):
def __init__(
self,
parent = None,
existingScript: str = ""
parent = None
):
super().__init__(parent)
@@ -42,9 +30,6 @@ class ALAutoScriptOrchDialog(QDialog):
self.setupUi()
self.connectSignals()
if existingScript and existingScript.strip():
self.loadFromScript(existingScript)
else:
self.addBlock()
self.scrollLayout.addStretch()
@@ -171,126 +156,3 @@ class ALAutoScriptOrchDialog(QDialog):
QMessageBox.warning(self, "提示", "脚本内容为空,请添加至少一个操作步骤。")
return
self.accept()
@staticmethod
def precheckScriptForOrchestration(
script: str
) -> tuple[bool, str]:
return precheck(script, allowed_vars=PRESET_NAMES)
def loadFromScript(
self,
script: str
):
if not script.strip():
self.addBlock()
return
ok, err = self.precheckScriptForOrchestration(script)
if not ok:
QMessageBox.warning(
self, "无法编排",
f"脚本检查失败:\n{err}\n\n"
"请通过\"编辑\"按钮打开脚本编辑窗口进行修改。"
)
self.addBlock()
return
# Structured block data via observer-based parsing — no duplicate logic
typeIdxMap = {"IF": 0, "ELSE IF": 1, "ELSE": 2}
parsedBlocks = parseBlocks(script)
self._blocks.clear()
while self.scrollLayout.count():
item = self.scrollLayout.takeAt(0)
if item.widget():
item.widget().deleteLater()
try:
for blockType, condition, actions in parsedBlocks:
self.addBlock()
block = self._blocks[-1]
idx = typeIdxMap.get(blockType, 0)
block.typeCombo.setCurrentIndex(idx)
block.onTypeChanged(idx)
for oldStep in list(block._actionWidgets):
block.removeActionStep(oldStep)
for target, valueExpr, opType in actions:
block.addActionStep()
step = block.getActionSteps()[-1]
step.setOpType(opType)
step.loadFromScript(target, valueExpr)
if blockType in ("IF", "ELSE IF") and condition:
self._parseConditions(block, condition)
except Exception:
self._blocks.clear()
while self.scrollLayout.count():
item = self.scrollLayout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self._updateBlockTypeRestrictions()
if not self._blocks:
self.addBlock()
def _parseConditions(
self,
block: ConditionalBlock,
condStr: str
):
s = condStr.strip()
if not s:
return
s = stripOuterParens(s)
orParts = splitTopLevel(s, ".OR.")
allSubConds = []
allLogics = []
for pi, part in enumerate(orParts):
part = part.strip()
if pi > 0:
allLogics.append(".OR.")
andParts = splitTopLevel(part, ".AND.")
for ai, ap in enumerate(andParts):
ap = ap.strip()
if ai > 0:
allLogics.append(".AND.")
allSubConds.append(ap)
for row in list(block._conditionRows):
block.condRowsLayout.removeWidget(row)
row.hide()
row.deleteLater()
block._conditionRows.clear()
for i, subCond in enumerate(allSubConds):
subCond = subCond.strip()
subCond = stripOuterParens(subCond)
isFirst = (i == 0)
row = ConditionRowFrame(
self._varMgr, block.blockIndex,
isFirst=isFirst, parent=block
)
if not isFirst:
row.deleteBtn.clicked.connect(
lambda _checked=False, r=row: block.removeConditionRow(r)
)
if i - 1 < len(allLogics):
logic = allLogics[i - 1]
for li in range(row.logicCombo.count()):
if row.logicCombo.itemData(li) == logic:
row.logicCombo.setCurrentIndex(li)
break
block._conditionRows.append(row)
block.condRowsLayout.addWidget(row)
subUp = subCond.upper()
if subUp in (".TRUE.", ".FALSE."):
row.loadFromParts(subUp, "", "")
else:
opSyms = [op for _, op in COMPARE_OPERATORS]
result = findOperatorIn(subCond, opSyms)
if result:
idx, op = result
leftPart = subCond[:idx].strip()
rightPart = subCond[idx + len(op):].strip()
row.loadFromParts(leftPart, op, rightPart)
else:
row.loadFromParts(subCond, "", "")
if not block._conditionRows:
block.addInitialConditionRow()
+84 -7
View File
@@ -271,6 +271,7 @@ class _DateInputContainer(QWidget):
):
super().__init__(parent)
self._dynamicItems = {} # index -> raw expression, for one-way parsed items
self.setupUi()
@@ -303,6 +304,9 @@ class _DateInputContainer(QWidget):
layout.addWidget(self._stack)
layout.addStretch()
_RE_CURRENT_DATE_OFFSET = re.compile(
r'^CURRENT_DATE\s*([+-])\s*(\d+)$', re.IGNORECASE
)
def getValue(
self
@@ -310,6 +314,9 @@ class _DateInputContainer(QWidget):
mode = self._modeCombo.currentData()
if mode == "relative":
idx = self._relCombo.currentIndex()
if idx in self._dynamicItems:
return self._dynamicItems[idx]
return self._relCombo.currentText()
return self._dateEdit.date().toString("yyyy-MM-dd")
@@ -331,6 +338,23 @@ class _DateInputContainer(QWidget):
if idx is not None:
self._modeCombo.setCurrentIndex(0)
self._relCombo.setCurrentIndex(idx)
elif self._RE_CURRENT_DATE_OFFSET.match(s):
m = self._RE_CURRENT_DATE_OFFSET.match(s)
sign = m.group(1)
n = int(m.group(2))
offset = n if sign == "+" else -n
label = f"{n}天后" if offset >= 0 else f"{n}天前"
raw = f"CURRENT_DATE {'+' if sign == '+' else '-'} {n}"
self._modeCombo.setCurrentIndex(0)
# Add dynamic item if not already present
for ci in range(self._relCombo.count()):
if ci in self._dynamicItems and self._dynamicItems[ci] == raw:
self._relCombo.setCurrentIndex(ci)
return
idx = self._relCombo.count()
self._relCombo.addItem(label)
self._dynamicItems[idx] = raw
self._relCombo.setCurrentIndex(idx)
elif s.startswith("DATE("):
self._modeCombo.setCurrentIndex(1)
m = re.match(r"DATE\((\d{4}-\d{2}-\d{2})\)", s)
@@ -565,13 +589,14 @@ def encodeValueStr(
if var_type == "Time":
if raw_value.startswith("+") or raw_value.startswith("-"):
return raw_value
if raw_value.startswith("TIME_OFFSET"):
m = re.match(r"TIME_OFFSET\(([+-]\d+),(\w+)\)", raw_value)
if m:
return m.group(1)
if raw_value.upper().startswith("TIME("):
return raw_value
return f"TIME({raw_value})"
if var_type == "Date":
if raw_value.upper().startswith("DATE("):
return raw_value
if raw_value.upper().startswith("CURRENT_DATE"):
return raw_value
relMap = {
"前天": "CURRENT_DATE - 2",
"昨天": "CURRENT_DATE - 1",
@@ -611,8 +636,11 @@ def stripOuterParens(
return s
# Pre-compiled pattern for detecting arithmetic expressions like "A + B" / "C - D"
_RE_ARITH_EXPR = re.compile(r'^.+?\s+[+-]\s+.+$')
# Pre-compiled patterns for detecting arithmetic expressions (A + B / A - B)
# Must match both spaced form (CURRENT_DATE + 1) and no-space form (RESERVE_DATE+1),
# consistent with ASEngine._resolveArithExpr.
_RE_ARITH_SPACED = re.compile(r'^(.+?)\s+([+-])\s+(.+)$')
_RE_ARITH_NOSPACE = re.compile(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$')
def isArithExpr(
@@ -622,7 +650,56 @@ def isArithExpr(
Return True if expr looks like a two-operand arithmetic expression (A ± B).
"""
return bool(_RE_ARITH_EXPR.match(expr.strip()))
s = expr.strip()
return bool(_RE_ARITH_SPACED.match(s) or _RE_ARITH_NOSPACE.match(s))
# Pre-compiled patterns for SET value whitelist validation,
# matching the priority order of ASEngine._resolveValue.
_RE_VAL_TIME = re.compile(r'^TIME\(\d{1,2}:\d{2}\)$', re.IGNORECASE)
_RE_VAL_DATE = re.compile(r'^DATE\(\d{4}-\d{2}-\d{2}\)$', re.IGNORECASE)
_RE_VAL_ARITH_SPACED = _RE_ARITH_SPACED
_RE_VAL_ARITH_NOSPACE = _RE_ARITH_NOSPACE
_RE_VAL_VAR_REF = re.compile(r'^[A-Z_][A-Z0-9_]*$', re.IGNORECASE)
def _isValidSetValue(
value: str
) -> bool:
"""
Whitelist validation: return True if value matches one of the
legal SET-value patterns recognised by ASEngine._resolveValue.
Order matches _resolveValue priority: TIME → DATE → bool →
quoted string → int → float → arith expr → variable reference.
"""
s = value.strip()
if not s:
return False
if _RE_VAL_TIME.match(s):
return True
if _RE_VAL_DATE.match(s):
return True
if s.upper() in (".TRUE.", ".FALSE."):
return True
if (s.startswith("'") and s.endswith("'")) or (s.startswith('"') and s.endswith('"')):
return True
try:
int(s)
return True
except ValueError:
pass
try:
float(s)
return True
except ValueError:
pass
if _RE_VAL_ARITH_SPACED.match(s) or _RE_VAL_ARITH_NOSPACE.match(s):
return True
if _RE_VAL_VAR_REF.match(s):
return True
return False
def isVarReference(
@@ -1,109 +0,0 @@
"""
Orchestration observer for AutoScript scripts.
Subscribes to ASTokenizer parsing events to produce a structured
block representation for the orchestration dialog UI.
"""
from autoscript.ASObserver import ParsingObserver
from autoscript.ASTokenizer import (
ASTokenizer,
K_IF,
K_ELSE_IF,
K_ELSE,
K_ENDIF,
K_SET,
K_ADD,
K_SUB,
)
__all__ = ["ScriptOrchObserver", "parseBlocks"]
class ScriptOrchObserver(ParsingObserver):
"""
Builds an ordered list of (block_type, condition, actions) tuples
from tokenization events.
Each block:
(type: str, condition: str | None, actions: list[(target, value_expr, op_type)])
"""
def __init__(
self
):
super().__init__()
self._blocks = []
self._current_type = None
self._current_condition = None
self._current_actions = []
def onTokenParsed(
self,
kind: str | None,
data,
line_num: int,
raw_line: str
):
if kind in (K_IF, K_ELSE_IF, K_ELSE):
self._flushCurrentBlock()
self._current_type = kind
self._current_condition = data if kind != K_ELSE else None
self._current_actions = []
elif kind in (K_SET, K_ADD, K_SUB):
target, value = data
if kind == K_SET:
self._current_actions.append((target, value, "set"))
elif kind == K_ADD:
prefixed = value if value.startswith("-") else f"+{value}"
self._current_actions.append((target, prefixed, "add"))
else:
prefixed = value if value.startswith("-") else f"-{value}"
self._current_actions.append((target, prefixed, "sub"))
elif kind == K_ENDIF:
self._flushCurrentBlock()
self._current_type = None
self._current_condition = None
self._current_actions = []
def onParseComplete(
self,
statements: list
):
self._flushCurrentBlock()
def _flushCurrentBlock(
self
):
if self._current_type is not None:
self._blocks.append((
self._current_type,
self._current_condition,
list(self._current_actions),
))
@property
def blocks(
self
) -> list:
return list(self._blocks)
def parseBlocks(
script: str
) -> list:
"""
Tokenize a script via observer pipeline and return its
structured block representation.
"""
observer = ScriptOrchObserver()
ASTokenizer.tokenizeWithObservers(script, [observer])
return observer.blocks
-163
View File
@@ -1,163 +0,0 @@
"""
Pre-check observer for AutoScript scripts.
Subscribes to ASTokenizer parsing events to validate script syntax
before it reaches the orchestration dialog, eliminating duplicate parsing.
"""
from autoscript.ASObserver import ParsingObserver
from autoscript.ASTokenizer import (
K_IF,
K_ELSE_IF,
K_ELSE,
K_ENDIF,
K_SET,
K_ADD,
K_SUB,
ASTokenizer,
)
__all__ = ["ScriptPrecheckObserver", "precheck"]
class ScriptPrecheckObserver(ParsingObserver):
"""
Validates script syntax and structure during tokenization.
Checks performed:
- IF/ENDIF depth matching
- No nested IF blocks (orchestration limitation)
- ELSE IF / ELSE appear only inside an IF block
- Only allowed variables appear in SET/ADD/SUB targets
- No completely unrecognized syntax lines
"""
def __init__(
self,
allowed_vars: set = None
):
super().__init__()
self._allowed = allowed_vars or set()
self._if_depth = 0
self.errors = []
self._stmts = []
def onTokenParsed(
self,
kind: str | None,
data,
line_num: int,
raw_line: str
):
if kind == K_IF:
self._if_depth += 1
if self._if_depth > 1:
self.errors.append(
f"静态检查:错误(第{line_num}行): 检测到嵌套 IF,编排窗口不支持嵌套条件块。"
)
elif kind == K_ELSE_IF:
if self._if_depth < 1:
self.errors.append(
f"静态检查:错误(第{line_num}行): ELSE IF 前缺少 IF。"
)
elif kind == K_ELSE:
if self._if_depth < 1:
self.errors.append(
f"静态检查:错误(第{line_num}行): ELSE 前缺少 IF。"
)
elif kind == K_ENDIF:
self._if_depth -= 1
if self._if_depth < 0:
self.errors.append(
f"静态检查:错误(第{line_num}行): 多余的 ENDIF。"
)
elif kind is None:
self.errors.append(
f"静态检查:错误(第{line_num}行): 无法识别的语法 '{raw_line}'"
)
elif kind in (K_SET, K_ADD, K_SUB):
target = data[0] if isinstance(data, tuple) else ""
if self._allowed and target.upper() not in self._allowed:
self.errors.append(
f"静态检查:错误(第{line_num}行): 目标变量 '{target}' 不是预设变量,"
f"编排窗口不支持。"
)
def onParseComplete(
self,
statements: list
):
if self._if_depth != 0:
self.errors.append(
f"静态检查:错误(不适用): IF 与 ENDIF 不匹配。")
self._stmts = statements
@property
def valid(
self
) -> bool:
return len(self.errors) == 0
def getErrorMessage(
self
) -> str:
return self.errors[0] if self.errors else ""
def buildSimplifiedScript(
self
) -> str:
"""Replace all non-control-flow statements with PASS for engine validation."""
lines = []
for stmt in self._stmts:
if stmt.kind in (K_IF, K_ELSE_IF, K_ELSE, K_ENDIF):
lines.append(stmt.raw_line)
else:
lines.append("PASS")
return "\n".join(lines)
def precheck(
script: str,
allowed_vars: set = None
) -> tuple[bool, str]:
"""
Run the full precheck pipeline on a script.
Steps:
1. Create a ScriptPrecheckObserver and subscribe it to an ASTokenizer.
2. Tokenize — the observer validates syntax during token events.
3. Replace action lines with PASS and run engine validation
with mock target data.
"""
if not script or not script.strip():
return True, ""
observer = ScriptPrecheckObserver(allowed_vars=allowed_vars)
ASTokenizer.tokenizeWithObservers(script, [observer])
if not observer.valid:
return False, observer.getErrorMessage()
simplified = observer.buildSimplifiedScript()
if not simplified.strip():
return True, ""
try:
from autoscript import (
registerDefaultTargetVars,
buildMockTargetData,
execute
)
registerDefaultTargetVars()
execute(simplified, buildMockTargetData())
except ValueError as e:
return False, f"运行时检查: {e}"
except Exception:
return False, "执行环境异常,请检查 AutoScript 配置。"
return True, ""
+28 -222
View File
@@ -1,8 +1,6 @@
"""
Widget components for the AutoScript orchestration dialog.
"""
import re
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
@@ -24,13 +22,11 @@ from gui.ALAutoScriptOrchDialog._helpers import (
encodeValueStr,
getValueFromWidget,
isArithExpr,
isVarReference,
makeComboWidget,
makeLabel,
makeOffsetWidget,
makeValueWidget,
makeVarRefCombo,
setWidgetValue,
)
@@ -114,7 +110,23 @@ class ConditionRowFrame(QFrame):
self
):
wasBool = self._isBoolMode
boolName = None
if wasBool:
data = self.leftVarCombo.currentData()
if data:
boolName = data[0]
self._varMgr.populateCombo(self.leftVarCombo)
# Append boolean literal sentinels at the end
self.leftVarCombo.insertSeparator(self.leftVarCombo.count())
self.leftVarCombo.addItem(".TRUE.", (".TRUE.", "Boolean"))
self.leftVarCombo.addItem(".FALSE.", (".FALSE.", "Boolean"))
if wasBool and boolName:
for ci in range(self.leftVarCombo.count()):
d = self.leftVarCombo.itemData(ci)
if d and d[0] == boolName:
self.leftVarCombo.setCurrentIndex(ci)
break
def populateRhsVarCombo(
@@ -143,7 +155,13 @@ class ConditionRowFrame(QFrame):
data = self.leftVarCombo.itemData(idx)
if not data:
return
_, vartype = data
name, vartype = data
isBool = name in (".TRUE.", ".FALSE.")
self._isBoolMode = isBool
self.opCombo.setVisible(not isBool)
self._compTypeCombo.setVisible(not isBool)
self.rhsStack.setVisible(not isBool)
if not isBool:
self.updateRhsLiteralWidget(vartype)
@@ -181,6 +199,8 @@ class ConditionRowFrame(QFrame):
) -> str:
data = self.leftVarCombo.currentData()
if self._isBoolMode and data:
return data[0]
if not data:
return ""
name, vartype = data
@@ -205,95 +225,6 @@ class ConditionRowFrame(QFrame):
return ""
def loadFromParts(
self,
operandName: str,
opSym: str,
valueExpr: str
):
self._rawRhsExpr = ""
self.leftVarCombo.blockSignals(True)
self.opCombo.blockSignals(True)
self._compTypeCombo.blockSignals(True)
try:
for ci in range(self.leftVarCombo.count()):
d = self.leftVarCombo.itemData(ci)
if d and d[0] == operandName:
self.leftVarCombo.setCurrentIndex(ci)
break
if opSym:
for oi in range(self.opCombo.count()):
if self.opCombo.itemData(oi) == opSym:
self.opCombo.setCurrentIndex(oi)
break
finally:
self.leftVarCombo.blockSignals(False)
self.opCombo.blockSignals(False)
self._compTypeCombo.blockSignals(False)
data = self.leftVarCombo.currentData()
vartype = data[1] if data else "String"
self.updateRhsLiteralWidget(vartype)
if not valueExpr:
return
up = valueExpr.strip().upper()
if isVarReference(valueExpr) or self._isKnownVar(up):
self._compTypeCombo.setCurrentIndex(1)
self.populateRhsVarCombo()
found = self._varMgr.findExactNameEntry(self.rhsVarCombo, up)
if found >= 0:
self.rhsVarCombo.setCurrentIndex(found)
else:
self.rhsVarCombo.addItem(up, (up, "String"))
self.rhsVarCombo.setCurrentIndex(self.rhsVarCombo.count() - 1)
elif isArithExpr(valueExpr):
self._tryLoadCondArithExpr(valueExpr, vartype)
else:
self._compTypeCombo.setCurrentIndex(0)
w = self.literalWidgets.get(vartype)
if w:
setWidgetValue(w, vartype, valueExpr)
def _tryLoadCondArithExpr(
self,
expr: str,
vartype: str
):
"""Try to decompose a condition RHS arithmetic expression into UI state."""
s = expr.strip()
m = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s)
if not m:
self._rawRhsExpr = s
return
left = m.group(1).strip()
op = m.group(2).strip()
right = m.group(3).strip()
left_up = left.upper()
if vartype == "Date" and left_up == "CURRENT_DATE":
try:
n = int(right)
offset = n if op == "+" else -n
if offset in (-2, -1, 0, 1, 2):
self._compTypeCombo.setCurrentIndex(0)
w = self.literalWidgets.get("Date")
if w and hasattr(w, "setValue"):
w.setValue(s)
return
except ValueError:
pass
self._rawRhsExpr = s
def _isKnownVar(
self,
name: str
) -> bool:
return self._varMgr.getInfoByName(name) is not None
def refreshVarCombos(
self
):
@@ -301,7 +232,6 @@ class ConditionRowFrame(QFrame):
self.populateLeftVarCombo()
self.populateRhsVarCombo()
class ActionStepFrame(QFrame):
def __init__(
@@ -471,9 +401,11 @@ class ActionStepFrame(QFrame):
) -> str:
target = self.getTargetName()
op = self.opTypeCombo.currentData()
if op == "pass":
return " PASS"
if not target:
return ""
op = self.opTypeCombo.currentData()
rawVal = self._getValueRaw()
if op == "set":
vartype = self._currentTargetType
@@ -514,132 +446,6 @@ class ActionStepFrame(QFrame):
return ""
def setOpType(
self,
opType: str
):
for i in range(self.opTypeCombo.count()):
if self.opTypeCombo.itemData(i) == opType:
self.opTypeCombo.setCurrentIndex(i)
break
def loadFromScript(
self,
targetVar: str,
valueExpr: str
):
targetUp = targetVar.upper().strip()
for ci in range(self.targetCombo.count()):
d = self.targetCombo.itemData(ci)
if d and d[0] == targetUp:
self.targetCombo.setCurrentIndex(ci)
break
self._setValueFromExpr(valueExpr)
def _setValueFromExpr(
self,
expr: str
):
s = expr.strip()
if not s:
return
op = self.opTypeCombo.currentData()
if op in ("add", "sub") and s.startswith("-"):
s = s[1:]
self.opTypeCombo.setCurrentIndex(
2 if op == "add" else 1
)
up = s.upper()
if isVarReference(s):
self.valueSrcCombo.setCurrentIndex(1)
self._varMgr.populateCombo(self.existingVarCombo)
idx = self._varMgr.findExactNameEntry(self.existingVarCombo, up)
if idx >= 0:
self.existingVarCombo.setCurrentIndex(idx)
else:
self.existingVarCombo.addItem(up, (up, "String"))
self.existingVarCombo.setCurrentIndex(self.existingVarCombo.count() - 1)
elif isArithExpr(s):
self._tryLoadArithExpr(s)
else:
self.valueSrcCombo.setCurrentIndex(0)
w = self.valueStack.currentWidget()
if w:
setWidgetValue(w, self._currentTargetType, expr)
def _tryLoadArithExpr(
self,
expr: str
):
"""Try to decompose an arithmetic expression into UI state."""
s = expr.strip()
m = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s)
if not m:
self._storeAsCustomExpr(s)
return
left = m.group(1).strip()
op = m.group(2).strip()
right = m.group(3).strip()
left_up = left.upper()
# CURRENT_DATE ± N for Date targets — try relative combo for ±0/1/2,
# otherwise store as custom expression to preserve relative semantics
if self._currentTargetType == "Date" and left_up == "CURRENT_DATE":
try:
n = int(right)
offset = n if op == "+" else -n
if offset in (-2, -1, 0, 1, 2):
w = self._literalWidgets.get("Date")
if w and hasattr(w, "setValue"):
w.setValue(s)
self.valueSrcCombo.setCurrentIndex(0)
return
except ValueError:
pass
self._storeAsCustomExpr(s)
return
# CURRENT_TIME ± N for Time targets — map to add/sub with offset
if self._currentTargetType == "Time" and left_up == "CURRENT_TIME":
try:
hours = int(right)
if op == "-":
hours = -hours
self.opTypeCombo.setCurrentIndex(
1 if hours >= 0 else 2
)
self.valueSrcCombo.setCurrentIndex(0)
w = self._offsetWidgets.get("Time")
if w and hasattr(w, "setValue"):
w.setValue(str(abs(hours)))
return
except ValueError:
pass
self._storeAsCustomExpr(s)
def _storeAsCustomExpr(
self,
expr: str
):
"""Store a raw expression in the variable combo when it can't be decomposed."""
self.valueSrcCombo.setCurrentIndex(1)
self._varMgr.populateCombo(self.existingVarCombo)
found = self._varMgr.findExactNameEntry(self.existingVarCombo, expr)
if found < 0:
self.existingVarCombo.addItem(expr, (expr, self._currentTargetType))
self.existingVarCombo.setCurrentIndex(self.existingVarCombo.count() - 1)
else:
self.existingVarCombo.setCurrentIndex(found)
def refreshVarCombos(
self
):