Skip to content

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) — 日志输出框

技术特点

  1. 数据持久化:两个表格通过 IniFileName + Ident 属性与 ini 文件绑定,LoadParamGrid() / LoadListGrid() 装载,SaveParamGrid() / SaveListGrid() 保存。
  2. 运行时定义:参数(AddParam / AddParamComboItem / SetParamAsCombo / SetParamAlignment / SetParamTextColor)与列(AddColumn / SetColumnAsCombo / AddColumnComboItem)均在 FormCreate 中动态定义。
  3. 单元格外观:可通过 SetCellTextColor / SetCellAlignment / SetParamCellTextColor + InvalidateDataCell / InvalidateParamCell 在运行时修改单元格颜色与对齐。
  4. 已知 Bug 规避:当前 glcl 内置的 P4DListGrid.AddRow() / DeleteRow() 在“增 → 删 → 再增”时会触发 Access violation。示例提供 ListGridRowOps.py,通过“存盘 → 改 ini → LoadListGrid 整表重建”的方式实现 安全行操作(add_row / delete_row / clear_rows),并可用 USE_SAFE_ROW_OPS 开关切换回原生方法。
  5. 消息钩子兼容: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.方法 方式绑定