Files
2026-10-27 09:12:40 +08:00

247 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build portable WPF skills from this checkout; no network or third-party modules."""
import argparse
import json
import re
from pathlib import Path
from xml.etree import ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / 'skills'
LIB = ROOT / 'src/Shared/HandyControl_Shared'
DOC = ROOT / 'doc/source/handycontrol'
REMOTE = 'https://github.com/HandyOrg/HandyControl/blob/master/'
CLASS = re.compile(r'public\s+(?:(?:abstract|static|partial|sealed)\s+)*class\s+(\w+)(?:\s*:\s*([^\r\n{]+))?')
def read(path):
return path.read_text(encoding='utf-8-sig')
def slug(name):
return re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '-', name).lower()
def link(path):
rel = path.relative_to(ROOT).as_posix()
return f'[{rel}]({REMOTE}{rel})'
def source_index():
result = {}
for path in sorted((LIB / 'Controls').rglob('*.cs')):
for match in CLASS.finditer(read(path)):
result.setdefault(match[1], []).append((path, (match[2] or '').strip()))
return result
def short_example(text, names, index):
"""Keep only self-contained, XML-parseable examples without Demo dependencies."""
candidates = []
for match in re.finditer(r'```\s*(?:xml|xaml)\s*\n(.*?)```', text, re.S | re.I):
snippet = match[1].strip()
if len(snippet.splitlines()) > 16 or not any(f'<hc:{n}' in snippet for n in names):
continue
if re.search(r'x:Class|x:Static|HandyControlDemo|\b(?:langs|ex|local|viewModel|system):|\.(?:png|jpg|gif|xaml|ico)', snippet):
continue
if re.search(r'\{(?:StaticResource|DynamicResource|Binding)', snippet):
continue
try:
element = ET.fromstring('<Root xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" '
'xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" '
'xmlns:hc="https://handyorg.github.io/handycontrol">' + snippet + '</Root>')
except ET.ParseError:
continue
types = [e.tag.split('}')[1].split('.')[0] for e in element.iter()
if e.tag.startswith('{https://handyorg.github.io/handycontrol}')]
if not all(n in index for n in types):
continue
candidates.append(snippet)
return ('xml', min(candidates, key=len)) if candidates else None
def api_reference(names, index):
sections = ['# 源码 API 导航\n',
'下面是当前工作区的声明摘录,不是完整 C# 文件,不包含全部继承成员、访问器和条件编译条件。'
'属性是否可写、依赖属性默认值、枚举成员和目标框架限制,需打开对应源码核对。\n']
visited = set()
def append(name):
if name in visited or name not in index:
return
visited.add(name)
for path, base in index[name]:
text = read(path)
declarations = []
for line in text.splitlines():
line = line.strip()
if not line.startswith('public ') or 'DependencyProperty ' in line or 'RoutedEvent ' in line:
continue
# Declaration headers only: never present extracted implementation as compilable code.
line = re.split(r'\s*=>|\s*\{', line, maxsplit=1)[0]
declarations.append(line)
sections.extend([f'## {name}\n', f'源码:{link(path)}\n',
'```text\n' + '\n'.join(dict.fromkeys(declarations)) + '\n```\n'])
parent = base.split(',')[0].strip()
if parent in index:
append(parent)
for name in names:
append(name)
return '\n'.join(sections)
SETUP = '''程序集/NuGet 为 `HandyControl`,XAML 命名空间为
`xmlns:hc="https://handyorg.github.io/handycontrol"`,C# 控件命名空间为 `HandyControl.Controls`。
在 `App.xaml` 合并 `pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml`,
然后合并 `pack://application:,,,/HandyControl;component/Themes/Theme.xaml`。
已有主题配置时先检查,避免重复合并。版本和目标框架以使用方项目为准。
'''
NOTES = {
'numericUpDown': 'Value 是 double;范围属性是 Minimum / Maximum,步长是 Increment,不能照搬 Syncfusion 的 MinValue / MaxValue / Step。DecimalPlaces 为 int?,仅控制显示。ValueChanged 的事件参数使用 FunctionEventArgs<double>,读取 Info;绑定时显式指定 Mode=TwoWay。',
'dialog': 'Dialog.Show 返回覆盖层 Dialog,不等于 Window.ShowDialog。无 token 时依赖活动窗口的 AdornerDecorator;指定 token 时先配置 Dialog.Token 和 DialogContainer。关闭使用返回实例的 Close()。异步结果需要实现库要求的结果接口并引入扩展命名空间,不能直接假定存在任意 Task 返回值。',
'growl': 'Growl 是通知 API 与控件的组合。页面内通知需要 Panel 上的 Growl.GrowlParent 或 Growl.Token;多窗口使用唯一 token。普通通知与 Global 方法的显示范围不同。后台任务更新 UI 先切回 Dispatcher。',
'grid': '这里的栅格是 hc:Row 与 hc:Col,采用 24 列;不是 hc:Grid。布局使用 Span、Offset、Gutter 等当前源码属性。',
'loading': 'LoadingBase 是抽象基类,实际使用 LoadingLine 或 LoadingCircle;耗时任务异步执行,避免阻塞动画线程。',
'window': 'XAML 根元素改为 hc:Window 时,code-behind 的基类也必须是 HandyControl.Controls.Window。与 System.Windows.Window 同名,C# 使用别名或全名。',
'textBox': 'hc:TextBox 与原生 TextBox 的样式方案不同。先确定使用的类型,再设置标题、占位和验证属性;不要假定 WPF PasswordBox 或其他输入框都支持 Text 绑定。',
'passwordBox': '区分 HandyControl.Controls.PasswordBox 与原生 System.Windows.Controls.PasswordBox;核对 Password 的属性定义和绑定机制,不在日志中输出密码。',
'messageBox': '使用 HandyControl.Controls.MessageBox 全名或别名以避免与 System.Windows.MessageBox 混淆;按实际重载处理 MessageBoxResult。',
'pagination': '绑定页码与数据加载逻辑时检查页码基准、总数变化及边界,事件参数以源码声明为准。',
'propertyGrid': '先确认 SelectedObject 的实际类型及属性可读写性,再选择编辑器;自定义编辑器需要实现库内 PropertyEditorBase 约定。',
}
EXAMPLES = {
'numericUpDown': ('xml', '<hc:NumericUpDown Minimum="0" Maximum="100" Increment="0.5"\n DecimalPlaces="2" Value="{Binding Amount, Mode=TwoWay}"/>'),
'grid': ('xml', '<hc:Row Gutter="12">\n <hc:Col Span="12"><TextBlock Text="左列"/></hc:Col>\n <hc:Col Span="12"><TextBlock Text="右列"/></hc:Col>\n</hc:Row>'),
'loading': ('xml', '<hc:LoadingCircle IsRunning="{Binding IsBusy}"/>'),
'dialog': ('csharp', '// 在已显示且具备装饰层的 WPF 窗口的 UI 线程执行。\nvar dialog = HandyControl.Controls.Dialog.Show(new System.Windows.Controls.TextBlock\n{\n Text = "正在处理"\n});\n// 操作完成后关闭。\ndialog.Close();'),
'growl': ('xml', '<!-- 在窗口可视树中声明;加载后在 UI 线程调用 Growl.Success("保存成功")。 -->\n<StackPanel hc:Growl.GrowlParent="True"/>'),
}
def skill_files(name, title, description, body, refs=None):
files = {
f'{name}/SKILL.md': f'---\nname: {name}\ndescription: {json.dumps(description, ensure_ascii=False)}\n---\n\n# {title}\n\n{body.strip()}\n',
f'{name}/agents/openai.yaml': 'interface:\n' +
f' display_name: {json.dumps(title, ensure_ascii=False)}\n' +
' short_description: "Implement HandyControl WPF controls from verified source guidance"\n' +
f' default_prompt: "Use ${name} to implement the requested HandyControl WPF UI."\n',
}
for path, content in (refs or {}).items():
files[f'{name}/references/{path}'] = content
return files
def build():
index = source_index()
files, rows = {}, []
for doc in sorted((DOC / 'extend_controls').glob('*/index.md')):
text, key = read(doc), doc.parent.name
title = re.search(r'^title:\s*(.+)$', text, re.M)[1].strip()
names = list(dict.fromkeys(m[0] for m in CLASS.findall(text)))
names = [n for n in names if n in index]
if not names:
names = [n for n in index if n.lower() == key.lower()]
if not names:
raise ValueError(f'No source mapping for {doc}')
name = 'handycontrol-wpf-' + slug(key)
doc_url = f'https://handyorg.github.io/handycontrol/extend_controls/{key}/'
headings = [m[1].strip() for m in re.finditer(r'^#{1,3}\s+(.+)$', text, re.M)]
demos = sorted(p for p in (ROOT / 'src/Shared/HandyControlDemo_Shared').rglob('*')
if p.is_file() and any(p.name.lower() in (n.lower() + 'demo.xaml', n.lower() + 'demo.xaml.cs') for n in names))
styles = sorted(p for p in (LIB / 'Themes/Styles').glob('*.xaml') if p.stem.lower() in {n.lower() for n in names})
evidence = f'# {title} 使用导航\n\n文档:{link(doc)}\n\n在线文档:[控件说明]({doc_url})\n\n'
evidence += '按任务阅读原文中的章节:' + '、'.join(headings) + '。\n\n'
evidence += '## 当前源码\n\n' + '\n'.join('- ' + link(p) for n in names for p, _ in index[n]) + '\n\n'
evidence += '## 样式及官方示例\n\n' + '\n'.join('- ' + link(p) for p in styles + demos) + '\n'
body = f'''实现或排查 **{title}**。相关类型:{', '.join(f'`{n}`' for n in names)}。
## 接入
{SETUP}
## 实现
先读 [API 声明与继承关系](references/api.md),再按需求查看
[文档、样式与示例导航](references/usage.md)。源码路径相对于 HandyControl 仓库根目录;
在本仓库优先读本地文件,安装到其他项目后使用内附声明与链接,遇到版本差异检查实际引用版本的源码。
不要将 Demo 的 langs、ex:Lang、ViewModelLocator 或示例资源直接复制进使用方项目。
{NOTES.get(key, '结合基类确认内容模型、绑定属性和事件参数;集合控件区分 ItemsSource、选中项与 ItemTemplate;修改样式前核对资源键和模板部件。')}
用目标项目现有的 MVVM 方式绑定;需要回写时明确 TwoWay 和更新时机,
不要假定所有依赖属性默认双向绑定。原生 WPF 与 HandyControl 同名类型使用命名空间区分。
交付时运行目标框架的构建,并在窗口加载后检查模板、绑定错误、空值和相关输入边界;
无法运行 WPF 时明确说明尚未验证 UI 行为。
'''
example_pair = EXAMPLES.get(key) or short_example(text, names, index)
if example_pair:
language, example = example_pair
body += f'\n## 最小片段\n\n依赖上面的应用资源配置;Binding 路径由使用方 ViewModel 提供。\n\n```{language}\n{example}\n```\n'
files.update(skill_files(name, 'HandyControl WPF ' + title,
f'实现、配置或排查 HandyControl WPF 的 {title}({", ".join(names)})。适用于该扩展控件的 XAML、绑定、事件和模板;不用于 Avalonia 或其他厂商控件。',
body, {'api.md': api_reference(names, index), 'usage.md': evidence}))
rows.append((name, title, key, names))
catalog = '# 扩展控件索引\n\n每行对应可单独安装的 Skill。仅有原生样式需求时选择 handycontrol-wpf-native-styles。\n\n| Skill | 控件 | 类型 |\n|---|---|---|\n'
catalog += '\n'.join(f'| `{n}` | [{t}](https://handyorg.github.io/handycontrol/extend_controls/{k}/) | {", ".join(types)} |' for n, t, k, types in rows) + '\n'
setup = read(OUT / 'templates/getting-started.md')
files.update(skill_files('handycontrol-wpf', 'HandyControl WPF',
'在 WPF 项目中接入 HandyControl、选择控件、配置资源或排查 XAML。用于 HandyControl 使用方和本仓库控件开发,不用于 Avalonia 或 Syncfusion。',
'''先检查使用方 csproj、App.xaml、现有 MVVM 约定与实际 HandyControl 版本。
新项目或资源缺失时读 [接入与验证](references/getting-started.md)。
选择扩展控件时读 [控件索引](references/controls.md),再调用对应已安装 Skill;
未安装时可根据索引中的类型与官方文档继续,不假定其他 Skill 必然存在。
原生控件外观与主题问题读 [样式和主题](references/styling.md)。
本仓库 WPF 共享实现位于 src/Shared/HandyControl_Shared,示例位于
src/Shared/HandyControlDemo_Shared,项目入口位于 src/Net_GE45 和 src/Net_40。
src/Avalonia 是独立实现,不能混用。修改库时先查依赖属性、对应样式、Demo,再改动。
不要直接修改自动合并的 Themes/Theme.xaml;检查 Theme_GE45.txt / Theme_40.txt 和样式源文件。
保留已有工作区修改。输出实际变更、运行方法及验证限制。
''', {'getting-started.md': setup, 'controls.md': catalog, 'styling.md': read(OUT / 'templates/styling.md')}))
for name, title, desc, template in [
('handycontrol-wpf-theming', 'HandyControl WPF 主题', '配置 HandyControl WPF 的皮肤、资源字典、动态颜色和主题切换;排查资源键与控件样式,不用于 Avalonia。', 'styling.md'),
('handycontrol-wpf-native-styles', 'HandyControl WPF 原生控件样式', '为原生 WPF Button、TextBox、DataGrid、ListBox 等应用 HandyControl 样式和附加属性,区分原生与 hc 同名控件。', 'native-styles.md'),
]:
files.update(skill_files(name, title, desc,
SETUP + '\n按任务阅读 [配置及示例](references/usage.md)。先确认控件实际 CLR 类型和样式 TargetType,再修改资源。\n',
{'usage.md': read(OUT / 'templates' / template)}))
# Native styles are read from XML, so resource keys cannot drift silently.
native = files['handycontrol-wpf-native-styles/references/usage.md']
for doc in sorted((DOC / 'native_controls').glob('*/index.md')):
candidates = [p for p in (LIB / 'Themes/Styles').glob('*.xaml') if p.stem.lower() == doc.parent.name.lower()]
for path in candidates:
tree = ET.fromstring(read(path))
keys = [e.attrib['{http://schemas.microsoft.com/winfx/2006/xaml}Key'] for e in tree.iter()
if e.tag.endswith('}Style') and '{http://schemas.microsoft.com/winfx/2006/xaml}Key' in e.attrib]
native += f'\n## {doc.parent.name}\n\n{link(path)}\n\n显式样式键:' + '、'.join(f'`{k}`' for k in keys) + '\n'
files['handycontrol-wpf-native-styles/references/usage.md'] = native
files['catalog.json'] = json.dumps({'skills': sorted({p.split('/')[0] for p in files})}, indent=2) + '\n'
return files
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true', help='Check snapshots without writing')
args = parser.parse_args()
files = build()
stale = []
for rel, content in files.items():
path = OUT / rel
if args.check:
if not path.exists() or read(path) != content:
stale.append(rel)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding='utf-8', newline='\n')
expected = {p.split('/')[0] for p in files if p.endswith('/SKILL.md')}
stale.extend(str(p.parent.relative_to(OUT)) for p in OUT.glob('*/SKILL.md') if p.parent.name not in expected)
if stale:
raise SystemExit('Stale or unexpected skill files:\n' + '\n'.join(stale))
print(f'{"Checked" if args.check else "Generated"} {len(expected)} skills / {len(files)} files')
if __name__ == '__main__':
main()