SimpleGridDemo 项目分析¶
项目信息¶
- 项目名称: SimpleGridDemo
- 下载链接: SimpleGridDemo.rar
- 所属类别: 扩展网格 (ExtendGrids)
- 分析时间: 2026-10-15
- 核心组件: P4DParamGrid(参数表)、P4DListGrid(列表表)
运行截图¶

文件结构¶
SimpleGridDemo/
├── ListGridRowOps.py # ListGrid1 的“安全行操作”辅助模块(增/删/清空行)
├── TestParamGrid.ini # 两个表格共用的数据文件
├── TestParamGrid.py # 工程主程序
├── TestParamGrid.xml
├── Unit1.py # 主窗体(Form1)
├── Unit1.pydfm # 二进制窗体属性
├── Unit1.sct # 事件绑定脚本
├── Unit1.sfm # 文本窗体定义
主程序文件: TestParamGrid.py¶
# -*- coding: utf-8 -*-
"""
TestParamGrid.py - 工程主程序 (对应 Delphi 的 TestParamGrid.dpr)
运行方式: python TestParamGrid.py (需要 Windows + glcl)
"""
from glcl import *
from Unit1 import * # 引入主窗体类 Form1
def main():
# 初始化应用程序对象
Application.Initialize()
Application.Title = 'TestParamGrid'
Application.MainFormOnTaskbar = True
# 创建并显示主窗体 (Form1 的构造函数里会加载窗体布局、定义参数/列并装载 ini 数据)
MainForm = Form1(Application)
MainForm.Show()
# 隐藏控制台窗口 (如需看 print / 报错信息, 注释掉下面这一行)
FreeConsole()
# 进入消息循环, 窗口关闭后返回
Application.Run()
# 释放主窗体
MainForm.Free()
if __name__ == '__main__':
main()
单元文件: Unit1.py¶
# -*- coding: utf-8 -*-
"""
Unit1.py 提醒:此工程会在后续版本升级过程修复组件Bug后再次更新
窗体上有两个 glcl 内置的表格组件:
ParamGrid1 (P4DParamGrid) 参数表: 每行一个参数 [参数名称 | 值 | 说明]
ListGrid1 (P4DListGrid) 列表表: 每行一条记录, 每列一个字段
两个表格的数据都保存在同一个 ini 文件 (TestParamGrid.ini) 里:
[test] ParamGrid1 的数据 (Ident = 'test')
[userlist] ListGrid1 的数据 (Ident = 'userlist')
窗体的布局保存在 Unit1.sfm(文本) / Unit1.pydfm(二进制) 里, 由 LoadProps() 读取;
事件处理函数在本文件里用代码绑定 (Delphi 里是在 dfm 里写 OnClick = xxx)。
"""
import os
from glcl import *
# 行的增删要用到 ListGridRowOps, 见下面 USE_SAFE_ROW_OPS 的说明
import ListGridRowOps
# ----------------------------------------------------------------------------
# 常量
# ----------------------------------------------------------------------------
# "增 / 删 / 改 / 查" 演示里使用的额外参数的标识 (对应 Delphi 的 cExtraParamIdent)
cExtraParamIdent = 'testExtra'
# ini 文件名。
INI_FILE_NAME = 'TestParamGrid.ini'
# ListGrid1 在 ini 里使用的节名 (即 ListGrid1.Ident)
LIST_IDENT = 'userlist'
# ListGrid1 的列定义: (列标识 ident, 表头文字, 数据类型, 初始列宽)
# 数据类型: 'String' / 'Integer' / 'Boolean'
# 之所以集中写成一张表: 建列(AddColumn)和后面"安全增删行"(ListGridRowOps)都要用到
# 列标识和类型, 写在一处可以保证两边始终一致。
LIST_COLUMNS = [
('UserName', '用户', 'String', 50),
('Age', '年龄', 'Integer', 50),
('Vip', 'VIP', 'Boolean', 50),
('Other', '其他数据', 'Integer', 50), # 这一列是下拉框, 保存的是选中项的序号
]
# ----------------------------------------------------------------------------
# [重要] USE_SAFE_ROW_OPS: 是否用 Python 端的"安全行操作"代替 ListGrid1.AddRow()/DeleteRow()
# ----------------------------------------------------------------------------
# 现象: 当前 glcl 内置的 P4DListGrid.AddRow()/DeleteRow() 在 "增 -> 删 -> 再增" 时会报
# Access violation (Read of address 0), 控制台提示 "AddRow" called with invalid arguments。
# 原因: 删除行时被删行的单元格属性对象被释放但指针没清, 再次增行时又被访问到
# (详见 ListGridRowOps.py 的说明, 以及 delphi_patch/ 里的 Delphi 端修复)。
# 做法: 默认 True —— 增/删/清空行改走 ListGridRowOps (存盘 -> 改 ini -> LoadListGrid 整表重建),
# 完全不调用有问题的原生方法。
# 如果你已经按 delphi_patch/ 修复并重新编译了 glcl.pyd, 可以改成 False 直接用原生方法。
USE_SAFE_ROW_OPS = True
class Form1(Form):
"""主窗体"""
def __init__(self, owner):
self.Label1 = Label(self) # 标题 "P4D CRUD Demo"
self.Label2 = Label(self) # 副标题 "增删改查全部通过方法调用完成"
self.Label3 = Label(self) # ParamGrid1 上方的说明
self.Label4 = Label(self) # ListGrid1 上方的说明
self.ParamGrid1 = P4DParamGrid(self) # 参数表 (glcl 内置组件)
self.ListGrid1 = P4DListGrid(self) # 列表表 (glcl 内置组件)
self.btnParamAdd = Button(self) # 参数表: 增
self.btnParamDelete = Button(self) # 参数表: 删
self.btnParamModify = Button(self) # 参数表: 改
self.btnParamQuery = Button(self) # 参数表: 查
self.btnRowAdd = Button(self) # 列表表: 增
self.btnRowDelete = Button(self) # 列表表: 删
self.btnRowModify = Button(self) # 列表表: 改
self.btnRowQuery = Button(self) # 列表表: 查
self.Button1 = Button(self) # "取值" (演示读取 / 修改单元格外观)
self.Button3 = Button(self) # "saveparams" (保存两个表格)
self.Memo1 = Memo(self) # 日志输出框
# ------------------------------------------------------------------
# 第 2 步: 从 Unit1.pydfm 读取窗体布局
# ------------------------------------------------------------------
self.LoadProps(os.path.join(os.path.dirname(os.path.abspath(__file__)), "Unit1.pydfm"))
# ------------------------------------------------------------------
# 第 3 步: 绑定按钮事件 (对应 dfm 里的 OnClick = xxx)
# ------------------------------------------------------------------
self.btnParamAdd.OnClick = self.btnParamAddClick
self.btnParamDelete.OnClick = self.btnParamDeleteClick
self.btnParamModify.OnClick = self.btnParamModifyClick
self.btnParamQuery.OnClick = self.btnParamQueryClick
self.btnRowAdd.OnClick = self.btnRowAddClick
self.btnRowDelete.OnClick = self.btnRowDeleteClick
self.btnRowModify.OnClick = self.btnRowModifyClick
self.btnRowQuery.OnClick = self.btnRowQueryClick
self.Button1.OnClick = self.Button1Click
self.Button3.OnClick = self.Button3Click
# ------------------------------------------------------------------
# 第 4 步: 相当于 Delphi 的 FormCreate (Python 里没有 OnCreate, 在构造函数末尾直接调用)
# ------------------------------------------------------------------
self.FormCreate(self)
# ======================================================================
# FormCreate: 定义参数 / 列, 并把 ini 里的数据装载进两个表格
# ======================================================================
def FormCreate(self, Sender):
self.Memo1.Lines.Clear()
# 把表格内部的提示消息 ("选择格子" / "左键点击" ...) 显示到 Memo, 见 HookMsg
self.HookMsg(self.ParamGrid1)
self.HookMsg(self.ListGrid1)
# ini 文件完整路径, 两个表格共用这一个文件
self.IniPath = os.path.join(os.path.dirname(os.path.abspath(__file__)), INI_FILE_NAME)
# ---------------------------------------------------------------
# ParamGrid1: 键值参数表
# ---------------------------------------------------------------
self.ParamGrid1.IniFileName = self.IniPath # 数据保存在哪个 ini 文件
self.ParamGrid1.Ident = 'test' # 使用 ini 里的 [test] 这一节
# AddParam(参数标识, 显示名称, 说明, 数据类型, 默认值)
# 数据类型: String / Integer / Boolean / Date / Time / DateTime
# 默认值一律用文本传入, 按数据类型解析; ini 里没有这个参数时就用默认值。
self.ParamGrid1.AddParam('test01', '开关', '布尔测试', 'Boolean', '1') # 显示为复选框
self.ParamGrid1.AddParam('test02', '日期', '日期测试', 'Date', '1982-01-20')
self.ParamGrid1.AddParam('test03', '日期时间', '日期时间测试', 'DateTime', '1982-03-20 08:00:01')
self.ParamGrid1.AddParam('test04', '时间', '时间测试', 'Time', '08:00:01')
self.ParamGrid1.AddParam('test05', '参数05', '整数测试', 'Integer', '10')
self.ParamGrid1.SetParamAlignment('test05', 'Center') # 对齐: Left / Right / Center
self.ParamGrid1.SetParamTextColor('test05', clGreen) # 文字颜色 (Delphi 版的 ccGreen)
# 下拉框参数: 保存的是选中项的序号 (Integer), 默认值 '0' 表示默认选第 0 项
self.ParamGrid1.AddParam('test06', '选择', '下拉选择', 'Integer', '0')
self.ParamGrid1.SetParamAsCombo('test06')
self.ParamGrid1.AddParamComboItem('test06', '0--关闭')
self.ParamGrid1.AddParamComboItem('test06', '1--打开')
# 所有参数定义完之后, 一次性装载 (从 ini 读值, 生成表格行)
self.ParamGrid1.LoadParamGrid()
# ---------------------------------------------------------------
# ListGrid1: 行列数据表
# ---------------------------------------------------------------
self.ListGrid1.IniFileName = self.IniPath # 与参数表共用同一个 ini 文件
self.ListGrid1.Ident = LIST_IDENT # 使用 ini 里的 [userlist] 这一节
# AddColumn(列标识, 表头文字, 数据类型, 初始列宽)
# 数据类型: String / Integer / Boolean (Boolean 显示为复选框)
for ident, caption, kind, width in LIST_COLUMNS:
self.ListGrid1.AddColumn(ident, caption, kind, width)
# 'Other' 列设置成下拉框, 候选项如下 (保存的是选中项的序号)
self.ListGrid1.SetColumnAsCombo('Other')
self.ListGrid1.AddColumnComboItem('Other', '001')
self.ListGrid1.AddColumnComboItem('Other', '002')
self.ListGrid1.AddColumnComboItem('Other', '003')
# 所有列定义完之后, 一次性装载 (行数、各行的值、列宽都来自 ini)
self.ListGrid1.LoadListGrid()
# ======================================================================
# "取值" 按钮: 演示读取下拉框的值, 以及运行时修改单元格的文字颜色 / 对齐
# ======================================================================
def Button1Click(self, Sender):
# 读取参数表下拉框 test06 当前选中项的序号
i = self.ParamGrid1.GetParamInt('test06')
self.DispMsg('choice test06:' + str(i))
# 把 test02 那个参数的值单元格改成红色字:
# 先改颜色属性, 再 Invalidate 通知表格重绘这个格子
self.ParamGrid1.SetParamCellTextColor('test02', clRed)
self.ParamGrid1.InvalidateParamCell('test02')
# 列表表: 第 0 行的 Age 单元格改成红色 + 居中显示
self.ListGrid1.SetCellTextColor('Age', 0, clRed)
self.ListGrid1.SetCellAlignment('Age', 0, 'Center')
self.ListGrid1.InvalidateDataCell('Age', 0)
# 读取第 2 行的 Age (行号 0 基; 要求列表里至少有 3 行, 否则会越界报错)
i = self.ListGrid1.GetCellInt('Age', 2)
self.DispMsg('row 2 Age:' + str(i))
# ======================================================================
# "saveparams" 按钮: 把两个表格里当前的内容写回 ini
# ======================================================================
def Button3Click(self, Sender):
self.ParamGrid1.SaveParamGrid()
self.ListGrid1.SaveListGrid()
self.DispMsg('All params saved!')
# ======================================================================
# ParamGrid1: 增 / 删 / 改 / 查
# ======================================================================
def btnParamAddClick(self, Sender):
"""增: 增加一个参数 testExtra (已存在则跳过)"""
if self.ParamGrid1.ParamExists(cExtraParamIdent):
self.DispMsg('[Param 增] ' + cExtraParamIdent + ' already exists, skipped.')
return
# 先定义参数, 再重新装载一次表格, 新参数才会显示出来
self.ParamGrid1.AddParam(cExtraParamIdent, '额外参数', '增删改查演示用', 'String', 'hello')
self.ParamGrid1.LoadParamGrid()
self.DispMsg('[Param 增] added ' + cExtraParamIdent + ' = ' +
self.ParamGrid1.GetParamString(cExtraParamIdent))
def btnParamDeleteClick(self, Sender):
"""删: 删除参数 testExtra (参数定义 + ini 里的值 + 表格里的行)"""
if not self.ParamGrid1.ParamExists(cExtraParamIdent):
self.DispMsg('[Param 删] ' + cExtraParamIdent + ' does not exist, add it first.')
return
self.ParamGrid1.DeleteParam(cExtraParamIdent)
self.DispMsg('[Param 删] deleted ' + cExtraParamIdent)
def btnParamModifyClick(self, Sender):
"""改: 把整数参数 test05 加 1"""
NewValue = self.ParamGrid1.GetParamInt('test05') + 1
self.ParamGrid1.SetParamInt('test05', NewValue)
self.DispMsg('[Param 改] test05 -> ' + str(NewValue))
def btnParamQueryClick(self, Sender):
"""查: 读取并显示 test05 的值"""
self.DispMsg('[Param 查] test05 = ' + str(self.ParamGrid1.GetParamInt('test05')))
# ======================================================================
# ListGrid1: 增 / 删 / 改 / 查 (行号一律是 0 基的"数据行号", 不含表头)
# ======================================================================
def btnRowAddClick(self, Sender):
"""增: 在末尾加一行, 并给新行填上初始值"""
NewRow = self.AddListRow() # 返回新行的 0 基行号
self.ListGrid1.SetCellString('UserName', NewRow, 'NewUser' + str(NewRow))
self.ListGrid1.SetCellInt('Age', NewRow, 18)
self.ListGrid1.SetCellBool('Vip', NewRow, False)
self.DispMsg('[Row 增] added row ' + str(NewRow) + ' UserName=' +
self.ListGrid1.GetCellString('UserName', NewRow))
def btnRowDeleteClick(self, Sender):
"""删: 删除最后一行"""
if self.ListGrid1.GetRowCount() <= 0:
self.DispMsg('[Row 删] no rows to delete.')
return
LastRow = self.ListGrid1.GetRowCount() - 1
self.DispMsg('[Row 删] deleting row ' + str(LastRow) + ' UserName=' +
self.ListGrid1.GetCellString('UserName', LastRow))
self.DeleteListRow(LastRow)
def btnRowModifyClick(self, Sender):
"""改: 把第 0 行的 Age 加 1"""
if self.ListGrid1.GetRowCount() <= 0:
self.DispMsg('[Row 改] no rows to modify.')
return
NewAge = self.ListGrid1.GetCellInt('Age', 0) + 1
self.ListGrid1.SetCellInt('Age', 0, NewAge)
self.DispMsg('[Row 改] row 0 Age -> ' + str(NewAge))
def btnRowQueryClick(self, Sender):
"""查: 显示第 0 行的内容"""
if self.ListGrid1.GetRowCount() <= 0:
self.DispMsg('[Row 查] no rows.')
return
self.DispMsg('[Row 查] row 0: UserName=' + self.ListGrid1.GetCellString('UserName', 0) +
' Age=' + str(self.ListGrid1.GetCellInt('Age', 0)) +
' Vip=' + str(self.ListGrid1.GetCellBool('Vip', 0)))
# ======================================================================
# 列表表 增 / 删 / 清空 行 —— 统一入口 (见上面 USE_SAFE_ROW_OPS 的说明)
# ======================================================================
def AddListRow(self):
"""在列表末尾增加一行, 返回新行的 0 基行号"""
if USE_SAFE_ROW_OPS:
return ListGridRowOps.add_row(self.ListGrid1, self.IniPath, LIST_IDENT,
self._ListColumnKinds())
return self.ListGrid1.AddRow()
def DeleteListRow(self, ARow):
"""删除 0 基行号为 ARow 的行"""
if USE_SAFE_ROW_OPS:
ListGridRowOps.delete_row(self.ListGrid1, self.IniPath, LIST_IDENT,
self._ListColumnKinds(), ARow)
else:
self.ListGrid1.DeleteRow(ARow)
def ClearListRows(self):
"""清空列表 (保留一个默认值行)。 本演示里没有按钮用到它, 留作需要时调用"""
if USE_SAFE_ROW_OPS:
ListGridRowOps.clear_rows(self.ListGrid1, self.IniPath, LIST_IDENT,
self._ListColumnKinds())
else:
self.ListGrid1.ClearRows()
@staticmethod
def _ListColumnKinds():
"""从 LIST_COLUMNS 取出 ListGridRowOps 需要的 [(列标识, 数据类型), ...]"""
return [(ident, kind) for ident, _caption, kind, _width in LIST_COLUMNS]
# ======================================================================
# 日志输出
# ======================================================================
def DispMsg(self, AMsg):
"""往 Memo 末尾追加一行日志"""
self.Memo1.Lines.Add(AMsg)
def OnDispMsgTing(self, AMsg):
"""表格内部消息的回调 (Delphi 版里赋给 Grid.OnMsg), 直接显示到 Memo"""
self.DispMsg(AMsg)
# ----------------------------------------------------------------------
# 原 Delphi 版: Grid.OnMsg := self.OnDispMsgTing
# 当前 glcl 的 P4DParamGrid / P4DListGrid 没有暴露 OnMsg 属性, 所以这里:
# 1) 先尝试直接给 OnMsg 赋值 (以后 glcl 暴露了就自动生效);
# 2) 不支持时, 用标准的 OnSelectCell / OnMouseUp 事件输出同样的提示
# ('选择格子' / '左键点击'); "参数已保存"一类表格内部消息在这种方式下得不到。
# 任何一步不可用都只是跳过, 不会抛异常。
# ----------------------------------------------------------------------
def HookMsg(self, Grid):
try:
Grid.OnMsg = self.OnDispMsgTing
return
except AttributeError:
pass
try:
Grid.OnSelectCell = self.GridSelectCell
except AttributeError:
pass
try:
Grid.OnMouseUp = self.GridMouseUp
except AttributeError:
pass
def GridSelectCell(self, Sender, ACol, ARow, CanSelect):
"""用户选中了某个单元格"""
self.DispMsg('选择格子')
def GridMouseUp(self, Sender, Button, Shift, X, Y):
"""鼠标在表格上抬起; 只关心左键"""
if Button == 'mbLeft' or Button == 0:
self.DispMsg('左键点击')
数据文件: TestParamGrid.ini¶
[test]
ParamNameWidth=64
ParamValueWidth=163
ParamMemoWidth=102
test01=1
test02=1982-2-21
test03=1982-12-31 8:00:01
test04=8:00:01
test05=25
test06=0
[userlist]
UserName_Width=50
Age_Width=36
Vip_Width=65
UserName_0=
Age_0=12
Vip_0=1
StripeRowCount=4
Other_Width=68
Other_0=0
UserName_1=NewUser1
Age_1=18
Vip_1=1
Other_1=0
UserName_2=NewUser2
Age_2=18
Vip_2=1
Other_2=0
UserName_3=NewUser3
Age_3=18
Vip_3=1
Other_3=0
辅助模块: ListGridRowOps.py¶
# -*- coding: utf-8 -*-
"""
ListGridRowOps.py - P4DListGrid "增行 / 删行 / 清空行" 的安全实现 (Python 端规避方案)
======================================================================================
背景: 为什么需要它
======================================================================================
glcl 内置的 P4DListGrid.AddRow() / DeleteRow() 在 "增 -> 删 -> 再增" 时会触发:
TypeError: "AddRow" called with invalid arguments.
Error: Access violation at address ... in module 'glcl.pyd'. Read of address 0000000000000000
原因 (根据 Delphi 源码 uSimpleGrid.pas 推断, 未能在 Delphi 环境中复现):
1. TStripeGrid.DeleteRow 直接调用 TStringGrid.DeleteRow 缩减行数。此时被删行单元格里的
Objects[] (指向 TCellProp 的指针) 仍留在网格内部;
2. 紧接着 LoadParams -> ClearAllCellProps 把所有 TCellProp 释放, 但它只会把"当前存在的行"
的 Objects 置 nil, 刚被删掉的那一行已经不在范围内, 指针成了悬空指针;
3. 再次 AddRow 时 RowCount + 1, 这一行重新出现, TStripe.LoadParam 读到的 Objects 不是 nil
而是已释放的对象, 访问它的 FComboBox 时发生 Access violation。
(ClearRows 和网格右键菜单里的 "删除鼠标所在的行" 走的是同一条缩减行数的路径, 同样有问题。)
======================================================================================
本模块的做法
======================================================================================
不调用 AddRow / DeleteRow / ClearRows, 而是:
① 用 Get*/ 读出网格当前所有行的值 (快照, 此时用户刚编辑的内容也在里面);
② 在 Python 里算出 "增 / 删 / 清空" 之后应有的行数据;
③ 直接改写 ini 文件里 "<列名>_<行号>" 与 StripeRowCount 这些键;
④ 调用 LoadListGrid() 让 glcl 整表重建。
LoadListGrid() 在重建时 "先释放全部单元格属性(此时行数还没变), 再改行数, 再装载数据",
这是程序启动时就在使用的、已被验证的路径, 不会留下悬空指针。
======================================================================================
使用前提
======================================================================================
* 网格的列定义 (ident / 类型) 需要传进来, 因为 ini 里的键名由列名决定;
* ini 文件由 Windows 的 INI API 读写 (Delphi TIniFile), 本模块按"系统 ANSI 代码页"编码读写,
与它保持一致, 并且只修改需要修改的行, 其余内容原样保留。
"""
import os
import sys
# 与 Delphi 源码 uSimpleGrid.pas 中的常量一致
STRIPE_ROW_COUNT_KEY = 'StripeRowCount'
# ----------------------------------------------------------------------------
# ini 文件的最小编辑器
# ----------------------------------------------------------------------------
class IniEditor:
"""
只做三件事: 读取键值 / 写入键值 / 删除键, 并且尽量不改动文件里的其他内容。
* 节名、键名不区分大小写 (与 Windows INI API 一致);
* 写入时保留原有的行顺序、注释和未涉及的键;
* 编码: 有 UTF-8 BOM 用 utf-8-sig, 有 UTF-16 BOM 用 utf-16, 否则 Windows 下按系统 ANSI
代码页(mbcs), 其他平台按 utf-8; 如果按上述方式解码失败, 退回 latin-1(逐字节无损往返),
保证绝不会因为编码问题破坏文件。
"""
def __init__(self, path):
self.path = path
self.encoding = 'mbcs' if os.name == 'nt' else 'utf-8'
self.lines = []
self._load()
# ---- 读文件 ----
def _load(self):
if not os.path.exists(self.path):
self.lines = []
return
with open(self.path, 'rb') as f:
raw = f.read()
if raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'):
self.encoding = 'utf-16'
elif raw.startswith(b'\xef\xbb\xbf'):
self.encoding = 'utf-8-sig'
try:
text = raw.decode(self.encoding)
except (UnicodeDecodeError, LookupError):
self.encoding = 'latin-1'
text = raw.decode('latin-1')
self.lines = text.splitlines()
# ---- 查找 ----
@staticmethod
def _is_section(line):
s = line.strip()
return s.startswith('[') and s.endswith(']')
def _section_range(self, section):
"""返回 (标题行下标, 下一个节标题的下标或文件末尾); 找不到返回 None"""
want = '[%s]' % section.lower()
start = None
for i, line in enumerate(self.lines):
if self._is_section(line):
if start is not None:
return start, i
if line.strip().lower() == want:
start = i
if start is not None:
return start, len(self.lines)
return None
def _find_key(self, section, key):
"""返回键所在行的下标, 找不到返回 -1"""
rng = self._section_range(section)
if rng is None:
return -1
for i in range(rng[0] + 1, rng[1]):
s = self.lines[i].strip()
if not s or s.startswith(';') or '=' not in s:
continue
if s.split('=', 1)[0].strip().lower() == key.lower():
return i
return -1
# ---- 对外接口 ----
def get(self, section, key, default=''):
i = self._find_key(section, key)
if i < 0:
return default
return self.lines[i].split('=', 1)[1]
def set(self, section, key, value):
value = str(value)
i = self._find_key(section, key)
if i >= 0:
# 保留原来的键名写法, 只替换值
old_key = self.lines[i].split('=', 1)[0]
self.lines[i] = '%s=%s' % (old_key, value)
return
rng = self._section_range(section)
if rng is None:
if self.lines and self.lines[-1].strip():
self.lines.append('')
self.lines.append('[%s]' % section)
self.lines.append('%s=%s' % (key, value))
return
# 插在该节最后一个非空行之后
pos = rng[1]
while pos > rng[0] + 1 and not self.lines[pos - 1].strip():
pos -= 1
self.lines.insert(pos, '%s=%s' % (key, value))
def delete(self, section, key):
i = self._find_key(section, key)
if i >= 0:
del self.lines[i]
def save(self):
data = ('\r\n'.join(self.lines) + ('\r\n' if self.lines else '')).encode(self.encoding)
d = os.path.dirname(self.path)
if d and not os.path.isdir(d):
os.makedirs(d)
with open(self.path, 'wb') as f:
f.write(data)
# ----------------------------------------------------------------------------
# 列定义 + 行快照
# ----------------------------------------------------------------------------
# 列定义: (ident, kind) 的列表, kind 为 'String' / 'Integer' / 'Boolean'
# 与 ListGrid1.AddColumn(ident, caption, kind, width) 中的 ident / kind 完全一致。
def _default_text(kind):
"""新增行时各类型列的默认值 (与 Delphi 版 TStripe 的默认值一致: 空串 / 0 / False)"""
return '0' if kind.lower() in ('integer', 'boolean') else ''
def _snapshot(grid, columns):
"""
读出网格当前所有数据行, 返回: [ {ident: 要写进 ini 的文本}, ... ] (按 0 基行号排列)
文本格式与 Delphi 的 SaveParam 写入 ini 的格式完全一致:
String -> 原文本; Integer -> 十进制整数(下拉框列为选中项序号); Boolean -> '1' / '0'
"""
rows = []
for r in range(grid.GetRowCount()):
row = {}
for ident, kind in columns:
k = kind.lower()
if k == 'integer':
row[ident] = str(grid.GetCellInt(ident, r))
elif k == 'boolean':
row[ident] = '1' if grid.GetCellBool(ident, r) else '0'
else:
row[ident] = grid.GetCellString(ident, r)
rows.append(row)
return rows
def _write_rows_and_reload(grid, ini_path, section, columns, rows, old_count):
"""
把 rows 作为网格的新内容写入 ini, 然后让网格整表重建。
* 写 "<列名>_<行号>" 键 (0 基), 以及行数 StripeRowCount;
* 删除多余的旧键 (行数变少时), 避免 ini 里残留过期数据, 日后再增行时被误读;
* 最后 LoadListGrid() 重建网格。
"""
ini = IniEditor(ini_path)
for i, row in enumerate(rows):
for ident, _kind in columns:
ini.set(section, '%s_%d' % (ident, i), row[ident])
for i in range(len(rows), max(old_count, len(rows))):
for ident, _kind in columns:
ini.delete(section, '%s_%d' % (ident, i))
ini.set(section, STRIPE_ROW_COUNT_KEY, len(rows))
ini.save()
grid.LoadListGrid()
def _prepare(grid):
"""
先让 glcl 把当前界面上的值(以及用户拖动过的列宽)写入 ini。
之后本模块只改写 "<列名>_<行号>" 与行数, 列宽等其他键保持 glcl 写入的结果。
"""
grid.SaveListGrid()
# ----------------------------------------------------------------------------
# 对外接口
# ----------------------------------------------------------------------------
def add_row(grid, ini_path, section, columns):
"""在末尾增加一行(各列为默认值), 返回新行的 0 基行号。 等价于 grid.AddRow()"""
_prepare(grid)
rows = _snapshot(grid, columns)
old_count = len(rows)
rows.append(dict((ident, _default_text(kind)) for ident, kind in columns))
_write_rows_and_reload(grid, ini_path, section, columns, rows, old_count)
return old_count
def delete_row(grid, ini_path, section, columns, row):
"""删除 0 基行号为 row 的行, 其后的行上移。 等价于 grid.DeleteRow(row)"""
_prepare(grid)
rows = _snapshot(grid, columns)
old_count = len(rows)
if row < 0 or row >= old_count:
raise IndexError('DeleteRow: index out of range: %d' % row)
del rows[row]
if not rows:
# 只剩一行时不删光, 恢复成一个默认值行 (保证表头固定行仍然有效)
rows.append(dict((ident, _default_text(kind)) for ident, kind in columns))
_write_rows_and_reload(grid, ini_path, section, columns, rows, old_count)
def clear_rows(grid, ini_path, section, columns):
"""清空所有行, 只保留一个默认值行。 等价于 grid.ClearRows()"""
_prepare(grid)
old_count = grid.GetRowCount()
rows = [dict((ident, _default_text(kind)) for ident, kind in columns)]
_write_rows_and_reload(grid, ini_path, section, columns, rows, old_count)
设计文件: Unit1.sct¶
def btnParamAddClick(Sender):
def btnParamDeleteClick(Sender):
def btnParamModifyClick(Sender):
def btnParamQueryClick(Sender):
def btnRowAddClick(Sender):
def btnRowDeleteClick(Sender):
def btnRowModifyClick(Sender):
def btnRowQueryClick(Sender):
def Button1Click(Sender):
def Button3Click(Sender):
设计文件: Unit1.sfm¶
object Form1: TForm
Left = 0
Top = 0
Caption = 'P4D-friendly grid demo (uSimpleGridP4D)'
ClientHeight = 620
ClientWidth = 900
Color = clBtnFace
Font.Charset = DEFAULT_CHARSET
Font.Color = clWindowText
Font.Height = -12
Font.Name = 'Segoe UI'
Font.Style = []
TextHeight = 15
object Label1: TLabel
Left = 16
Top = 340
Width = 600
Height = 25
Caption = 'P4D CRUD Demo'
Font.Charset = DEFAULT_CHARSET
Font.Color = clWindowText
Font.Height = -18
Font.Name = 'Segoe UI'
Font.Style = []
ParentFont = False
end
object Label2: TLabel
Left = 16
Top = 372
Width = 600
Height = 25
Caption = #22686#21024#25913#26597#20840#37096#36890#36807#26041#27861#35843#29992#23436#25104
Font.Charset = DEFAULT_CHARSET
Font.Color = clWindowText
Font.Height = -18
Font.Name = 'Segoe UI'
Font.Style = []
ParentFont = False
end
object Label3: TLabel
Left = 16
Top = 226
Width = 200
Height = 15
Caption = 'ParamGrid1 - '#22686#21024#25913#26597
end
object Label4: TLabel
Left = 456
Top = 226
Width = 200
Height = 15
Caption = 'ListGrid1 - '#22686#21024#25913#26597
end
object ParamGrid1: TP4DParamGrid
Left = 16
Top = 16
Width = 417
Height = 205
Options = [goFixedVertLine, goFixedHorzLine, goVertLine, goHorzLine, goDrawFocusSelected, goRowSizing, goColSizing, goEditing, goFixedRowDefAlign]
TabOrder = 0
end
object ListGrid1: TP4DListGrid
Left = 456
Top = 16
Width = 420
Height = 205
Options = [goFixedVertLine, goFixedHorzLine, goVertLine, goHorzLine, goDrawFocusSelected, goColSizing, goFixedRowDefAlign]
TabOrder = 1
end
object btnParamAdd: TButton
Left = 16
Top = 246
Width = 95
Height = 32
Caption = #22686' Add'
TabOrder = 2
OnClick = btnParamAddClick
end
object btnParamDelete: TButton
Left = 117
Top = 246
Width = 95
Height = 32
Caption = #21024' Delete'
TabOrder = 3
OnClick = btnParamDeleteClick
end
object btnParamModify: TButton
Left = 218
Top = 246
Width = 95
Height = 32
Caption = #25913' Modify'
TabOrder = 4
OnClick = btnParamModifyClick
end
object btnParamQuery: TButton
Left = 319
Top = 246
Width = 95
Height = 32
Caption = #26597' Query'
TabOrder = 5
OnClick = btnParamQueryClick
end
object btnRowAdd: TButton
Left = 456
Top = 246
Width = 95
Height = 32
Caption = #22686' Add'
TabOrder = 6
OnClick = btnRowAddClick
end
object btnRowDelete: TButton
Left = 557
Top = 246
Width = 95
Height = 32
Caption = #21024' Delete'
TabOrder = 7
OnClick = btnRowDeleteClick
end
object btnRowModify: TButton
Left = 658
Top = 246
Width = 95
Height = 32
Caption = #25913' Modify'
TabOrder = 8
OnClick = btnRowModifyClick
end
object btnRowQuery: TButton
Left = 759
Top = 246
Width = 95
Height = 32
Caption = #26597' Query'
TabOrder = 9
OnClick = btnRowQueryClick
end
object Button1: TButton
Left = 16
Top = 290
Width = 137
Height = 37
Caption = #21462#20540
TabOrder = 10
OnClick = Button1Click
end
object Button3: TButton
Left = 170
Top = 290
Width = 129
Height = 37
Caption = 'saveparams'
TabOrder = 11
OnClick = Button3Click
end
object Memo1: TMemo
Left = 16
Top = 420
Width = 860
Height = 180
Lines.Strings = (
'Memo1')
TabOrder = 12
end
end
详细分析¶
功能概述¶
本示例演示 glcl 内置的两个专用表格组件——P4DParamGrid(键值参数表) 与 P4DListGrid(行列数据表) 的完整“增、删、改、查(CRUD)”用法。两个表格的数据全部保存在同一个 ini 文件(TestParamGrid.ini)的不同节(section)中:
[test]节保存ParamGrid1的数据(Ident = 'test')[userlist]节保存ListGrid1的数据(Ident = 'userlist')
窗体布局保存在 Unit1.sfm(文本)/ Unit1.pydfm(二进制)中,由 LoadProps() 读取;事件处理函数在 Python 代码中绑定(相当于 Delphi 在 dfm 里写 OnClick = xxx)。
使用的组件¶
- Form1 (TForm) — 主窗体
- P4DParamGrid (ParamGrid1) — 参数表:每行一个参数
[参数名称 | 值 | 说明],支持 String / Integer / Boolean / Date / Time / DateTime 等数据类型,支持下拉框参数、文字颜色、对齐方式等。 - P4DListGrid (ListGrid1) — 列表表:每行一条记录、每列一个字段,支持 String / Integer / Boolean 数据类型与下拉框列。
- Button ×10 — 用于演示增删改查、取值、保存
- Label ×4 — 标题与说明
- Memo (Memo1) — 日志输出框
技术特点¶
- 数据持久化:两个表格通过
IniFileName+Ident属性与 ini 文件绑定,LoadParamGrid()/LoadListGrid()装载,SaveParamGrid()/SaveListGrid()保存。 - 运行时定义:参数(
AddParam/AddParamComboItem/SetParamAsCombo/SetParamAlignment/SetParamTextColor)与列(AddColumn/SetColumnAsCombo/AddColumnComboItem)均在FormCreate中动态定义。 - 单元格外观:可通过
SetCellTextColor/SetCellAlignment/SetParamCellTextColor+InvalidateDataCell/InvalidateParamCell在运行时修改单元格颜色与对齐。 - 已知 Bug 规避:当前 glcl 内置的
P4DListGrid.AddRow()/DeleteRow()在“增 → 删 → 再增”时会触发Access violation。示例提供ListGridRowOps.py,通过“存盘 → 改 ini →LoadListGrid整表重建”的方式实现 安全行操作(add_row/delete_row/clear_rows),并可用USE_SAFE_ROW_OPS开关切换回原生方法。 - 消息钩子兼容:
P4DParamGrid/P4DListGrid尚未暴露OnMsg属性,示例通过HookMsg()先尝试OnMsg,失败则退回OnSelectCell/OnMouseUp输出同样的提示,保证任何一步不可用都不会抛异常。
窗体属性¶
- Caption:
P4D-friendly grid demo (uSimpleGridP4D) - ClientWidth / ClientHeight: 900 × 620
- Font: Segoe UI
- 关键控件:
ParamGrid1(417×205,goEditing、goRowSizing、goColSizing等选项)ListGrid1(420×205,goColSizing等选项)- 一排“增/删/改/查”按钮(参数表 4 个 + 列表表 4 个)
Button1(取值)、Button3(saveparams)Memo1(860×180 日志框)
代码分析¶
导入的模块:
- os
- from glcl import *
- import ListGridRowOps
定义的类:
- Form1
主要方法:
- __init__ — 声明控件、LoadProps 加载布局、绑定事件、调用 FormCreate
- FormCreate — 定义参数与列,装载 ini 数据
- Button1Click — 演示读取下拉框值、修改单元格颜色/对齐
- Button3Click — 保存两个表格到 ini
- btnParamAddClick / btnParamDeleteClick / btnParamModifyClick / btnParamQueryClick — 参数表 CRUD
- btnRowAddClick / btnRowDeleteClick / btnRowModifyClick / btnRowQueryClick — 列表表 CRUD
- AddListRow / DeleteListRow / ClearListRows — 统一的行操作入口
- DispMsg / OnDispMsgTing / HookMsg — 日志与消息钩子
事件绑定:
- 所有按钮的 OnClick 在 __init__ 中以 self.控件.OnClick = self.方法 方式绑定