247 lines
16 KiB
Python
247 lines
16 KiB
Python
"""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()
|