Skip to content

Commit ab80e65

Browse files
authored
Merge pull request #292 from smalruby/feature/insert-class-button
feat: add class insertion button to Ruby tab More menu
2 parents b21a390 + 4254e21 commit ab80e65

7 files changed

Lines changed: 411 additions & 0 deletions

File tree

packages/scratch-gui/src/components/ruby-toolbar/messages.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ const messages = defineMessages({
8888
defaultMessage: 'Save Ruby script',
8989
description: 'Label for save Ruby script menu item'
9090
},
91+
insertClass: {
92+
id: 'gui.rubyToolbar.insertClass',
93+
defaultMessage: 'Insert class',
94+
description: 'Label for insert class menu item in More menu'
95+
},
9196
stage: {
9297
id: 'gui.rubyToolbar.stage',
9398
defaultMessage: 'Stage',

packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ const RubyToolbar = props => {
8383
setShowMoreMenu(prev => !prev);
8484
}, []);
8585

86+
const handleInsertClass = useCallback(() => {
87+
setShowMoreMenu(false);
88+
if (props.onDismissBubble) props.onDismissBubble();
89+
if (props.onInsertClass) props.onInsertClass();
90+
}, [props]);
91+
8692
const handleOpenAutoCorrectSettings = useCallback(() => {
8793
setShowMoreMenu(false);
8894
if (props.onOpenAutoCorrectSettings) props.onOpenAutoCorrectSettings();
@@ -246,6 +252,13 @@ const RubyToolbar = props => {
246252
/>
247253
{intl.formatMessage(messages.saveRubyScript)}
248254
</div>
255+
<div
256+
className={styles.moreMenuItem}
257+
onClick={handleInsertClass}
258+
>
259+
<span className={styles.moreMenuIcon}>{'{ }'}</span>
260+
{intl.formatMessage(messages.insertClass)}
261+
</div>
249262
<div
250263
className={styles.moreMenuItem}
251264
onClick={handleOpenAutoCorrectSettings}
@@ -271,6 +284,7 @@ RubyToolbar.propTypes = {
271284
editorRef: PropTypes.object,
272285
onSelectTarget: PropTypes.func.isRequired,
273286
onDownload: PropTypes.func,
287+
onInsertClass: PropTypes.func,
274288
onExecuteLine: PropTypes.func,
275289
onDismissBubble: PropTypes.func,
276290
onOpenGeminiModal: PropTypes.func,

packages/scratch-gui/src/containers/ruby-tab.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import FuriganaRenderer from './ruby-tab/furigana-renderer';
3434
import GeminiModalHOC from './gemini-modal-hoc.jsx';
3535
import collectMetadata from '../lib/collect-metadata.js';
3636
import {closeFileMenu} from '../reducers/menus.js';
37+
import {wrapCurrentCodeWithClass} from '../lib/insert-class';
3738
import {setAiSaveStatus, clearAiSaveStatus} from '../reducers/koshien-file';
3839
import AutoCorrectModal from '../components/auto-correct-modal/auto-correct-modal.jsx';
3940
import {autoCorrect, defaultSettings as defaultAutoCorrectSettings} from '../lib/auto-correct';
@@ -436,6 +437,21 @@ const RubyTab = props => {
436437
}
437438
}, [getSaveToComputerHandler]);
438439

440+
const handleInsertClass = useCallback(() => {
441+
if (!editorRef.current) return;
442+
const code = editorRef.current.getValue() || '';
443+
const target = vm.editingTarget;
444+
if (!target) return;
445+
const wrapped = wrapCurrentCodeWithClass(code, target);
446+
if (wrapped === null) return; // class already exists
447+
const model = editorRef.current.getModel();
448+
const fullRange = model.getFullModelRange();
449+
editorRef.current.executeEdits('insertClass', [{
450+
range: fullRange,
451+
text: wrapped
452+
}]);
453+
}, [vm]);
454+
439455
const handleAISaveFinished = useCallback(() => {
440456
onSetAiSaveStatus('saved');
441457
setTimeout(() => {
@@ -806,6 +822,7 @@ const RubyTab = props => {
806822
editorRef={editorRef.current}
807823
onSelectTarget={handleSelectTarget}
808824
onDownload={handleDownload}
825+
onInsertClass={handleInsertClass}
809826
onExecuteLine={handleExecuteLine}
810827
onDismissBubble={handleDismissBubbleStable}
811828
onOpenGeminiModal={onOpenGeminiModal}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// === Smalruby: This file is Smalruby-specific (class insertion utility for Ruby tab) ===
2+
3+
const INDENT = ' ';
4+
5+
/**
6+
* Quote a string for Ruby output (double-quoted).
7+
* @param {string} string The string to quote.
8+
* @returns {string} The quoted string.
9+
*/
10+
const quote = string => {
11+
const escapeChars = {
12+
'\\': '\\\\',
13+
'"': '\\"',
14+
'\n': '\\n',
15+
'\t': '\\t'
16+
};
17+
const s = String(string);
18+
const sb = ['"'];
19+
for (let i = 0; i < s.length; i++) {
20+
const ch = s.charAt(i);
21+
sb.push(escapeChars[ch] || ch);
22+
}
23+
sb.push('"');
24+
return sb.join('');
25+
};
26+
27+
/**
28+
* Prepend a prefix onto each line of code.
29+
* @param {string} text The lines of code.
30+
* @param {string} prefix The common prefix.
31+
* @returns {string} The prefixed lines of code.
32+
*/
33+
const prefixLines = (text, prefix = INDENT) =>
34+
prefix + text.replace(/(?!\n$)\n/g, `\n${prefix}`);
35+
36+
/**
37+
* Check if a string is a valid Ruby constant name (class name).
38+
* @param {string} name The name to check.
39+
* @returns {boolean} Whether the name is a valid class name.
40+
*/
41+
const isValidClassName = name => /^[A-Z][\p{L}\p{N}_]*$/u.test(name);
42+
43+
/**
44+
* Check if code already contains a class definition.
45+
* Matches lines starting with optional whitespace followed by "class ".
46+
* Ignores comments (lines starting with #).
47+
* @param {string} code The code to check.
48+
* @returns {boolean} Whether the code contains a class definition.
49+
*/
50+
const hasClassDefinition = code => {
51+
const lines = code.split('\n');
52+
for (const line of lines) {
53+
const trimmed = line.trimStart();
54+
if (trimmed.startsWith('#')) continue;
55+
if (/^class\s/.test(trimmed)) return true;
56+
}
57+
return false;
58+
};
59+
60+
/**
61+
* Generate set_xxx lines for sprite attributes that differ from defaults.
62+
* @param {object} target The sprite target.
63+
* @param {string[]} setLines Array to push set lines into.
64+
*/
65+
const generateSetXxx = (target, setLines) => {
66+
if (target.x !== 0) {
67+
setLines.push(`set_x ${target.x}`);
68+
}
69+
if (target.y !== 0) {
70+
setLines.push(`set_y ${target.y}`);
71+
}
72+
if (target.direction !== 90) {
73+
setLines.push(`set_direction ${target.direction}`);
74+
}
75+
if (!target.visible) {
76+
setLines.push(`set_visible ${!!target.visible}`);
77+
}
78+
if (target.size !== 100) {
79+
setLines.push(`set_size ${target.size}`);
80+
}
81+
if (target.currentCostume > 0) {
82+
setLines.push(`set_current_costume ${target.currentCostume + 1}`);
83+
}
84+
if (target.rotationStyle !== 'all around') {
85+
setLines.push(`set_rotation_style ${quote(target.rotationStyle)}`);
86+
}
87+
};
88+
89+
/**
90+
* Generate set_xxx lines for stage attributes that differ from defaults.
91+
* @param {object} target The stage target.
92+
* @param {string[]} setLines Array to push set lines into.
93+
*/
94+
const generateStageSetXxx = (target, setLines) => {
95+
if (target.currentCostume > 0) {
96+
setLines.push(`set_current_backdrop ${target.currentCostume + 1}`);
97+
}
98+
};
99+
100+
/**
101+
* Wrap existing code with a class definition.
102+
* Returns null if code already contains a class definition.
103+
* This replicates the logic of RubyGenerator._wrapWithClass with forFileOutput=true.
104+
* @param {string} code The current editor code.
105+
* @param {object} target The editing target (vm.editingTarget).
106+
* @returns {string|null} The wrapped code, or null if class already exists.
107+
*/
108+
const wrapCurrentCodeWithClass = (code, target) => {
109+
if (hasClassDefinition(code)) {
110+
return null;
111+
}
112+
113+
const isStage = target.isStage;
114+
let className;
115+
const setLines = [];
116+
117+
if (isStage) {
118+
className = 'Stage';
119+
if (target.sprite.name !== 'Stage') {
120+
setLines.push(`set_name ${quote(target.sprite.name)}`);
121+
}
122+
} else {
123+
const spriteName = target.sprite.name;
124+
if (isValidClassName(spriteName)) {
125+
className = spriteName;
126+
} else {
127+
const sprites = target.runtime.targets.filter(t => !t.isStage);
128+
const index = sprites.indexOf(target) + 1;
129+
className = `Sprite${index}`;
130+
setLines.push(`set_name ${quote(spriteName)}`);
131+
}
132+
}
133+
134+
// Generate set_xxx for non-default attributes
135+
if (isStage) {
136+
generateStageSetXxx(target, setLines);
137+
} else {
138+
generateSetXxx(target, setLines);
139+
}
140+
141+
let setCode = '';
142+
if (setLines.length > 0) {
143+
setCode = setLines.map(line => `${INDENT}${line}\n`).join('');
144+
}
145+
146+
// Split code into hat/def blocks vs other top-level code
147+
let bodyCode = code;
148+
let outsideCode = '';
149+
150+
if (bodyCode.length > 0) {
151+
const sections = bodyCode.split(/\n\n/);
152+
const insideSections = [];
153+
const outsideSections = [];
154+
for (const section of sections) {
155+
const trimmed = section.trim();
156+
if (trimmed.length === 0) continue;
157+
if (/^self\.when\(/.test(trimmed) ||
158+
/^when_/.test(trimmed) ||
159+
/^\w+\.when[\s_(]/.test(trimmed) ||
160+
/^def /.test(trimmed)) {
161+
insideSections.push(section);
162+
} else {
163+
outsideSections.push(section);
164+
}
165+
}
166+
bodyCode = insideSections.join('\n\n');
167+
if (bodyCode.length > 0 && !bodyCode.endsWith('\n')) {
168+
bodyCode += '\n';
169+
}
170+
if (outsideSections.length > 0) {
171+
const commented = outsideSections
172+
.join('\n\n')
173+
.split('\n')
174+
.map(line => (line.trim().length > 0 ? `# ${line}` : ''))
175+
.join('\n');
176+
outsideCode = `\n${commented}\n`;
177+
}
178+
}
179+
180+
if (bodyCode.length > 0) {
181+
bodyCode = prefixLines(bodyCode, INDENT);
182+
}
183+
184+
const separator = setCode.length > 0 && bodyCode.length > 0 ? '\n' : '';
185+
const inheritance = isStage ? '' : ' < ::Smalruby3::Sprite';
186+
let result = `class ${className}${inheritance}\n${setCode}${separator}${bodyCode}end\n`;
187+
188+
if (outsideCode.length > 0) {
189+
result += outsideCode;
190+
}
191+
192+
return result;
193+
};
194+
195+
export {wrapCurrentCodeWithClass, hasClassDefinition};

packages/scratch-gui/src/locales/ja-Hira.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ export default {
392392
'gui.rubyToolbar.moreOptions': 'そのたのオプション',
393393
'gui.rubyToolbar.autoCorrectSettings': 'じどうちかんせってい',
394394
'gui.rubyToolbar.saveRubyScript': 'ルビースクリプトをほぞん',
395+
'gui.rubyToolbar.insertClass': 'クラスをそうにゅう',
395396
'gui.autoCorrectModal.title': 'じどうちかんせってい',
396397
'gui.autoCorrectModal.fullwidthNumbers': 'ぜんかくすうじ → はんかくすうじ',
397398
'gui.autoCorrectModal.fullwidthAlpha': 'ぜんかくアルファベット → はんかくアルファベット',

packages/scratch-gui/src/locales/ja.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ export default {
392392
'gui.rubyToolbar.moreOptions': 'その他のオプション',
393393
'gui.rubyToolbar.autoCorrectSettings': '自動置換設定',
394394
'gui.rubyToolbar.saveRubyScript': 'ルビースクリプトを保存',
395+
'gui.rubyToolbar.insertClass': 'クラスを挿入',
395396
'gui.autoCorrectModal.title': '自動置換設定',
396397
'gui.autoCorrectModal.fullwidthNumbers': '全角数字 → 半角数字',
397398
'gui.autoCorrectModal.fullwidthAlpha': '全角アルファベット → 半角アルファベット',

0 commit comments

Comments
 (0)