diff --git a/packages/scratch-gui/src/components/ruby-toolbar/messages.js b/packages/scratch-gui/src/components/ruby-toolbar/messages.js index 0fc5f9c9c9f..a9913cd1c5a 100644 --- a/packages/scratch-gui/src/components/ruby-toolbar/messages.js +++ b/packages/scratch-gui/src/components/ruby-toolbar/messages.js @@ -88,6 +88,11 @@ const messages = defineMessages({ defaultMessage: 'Save Ruby script', description: 'Label for save Ruby script menu item' }, + insertClass: { + id: 'gui.rubyToolbar.insertClass', + defaultMessage: 'Insert class', + description: 'Label for insert class menu item in More menu' + }, stage: { id: 'gui.rubyToolbar.stage', defaultMessage: 'Stage', diff --git a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx index f30ad985021..e97cb812595 100644 --- a/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx +++ b/packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx @@ -83,6 +83,12 @@ const RubyToolbar = props => { setShowMoreMenu(prev => !prev); }, []); + const handleInsertClass = useCallback(() => { + setShowMoreMenu(false); + if (props.onDismissBubble) props.onDismissBubble(); + if (props.onInsertClass) props.onInsertClass(); + }, [props]); + const handleOpenAutoCorrectSettings = useCallback(() => { setShowMoreMenu(false); if (props.onOpenAutoCorrectSettings) props.onOpenAutoCorrectSettings(); @@ -246,6 +252,13 @@ const RubyToolbar = props => { /> {intl.formatMessage(messages.saveRubyScript)} +
+ {'{ }'} + {intl.formatMessage(messages.insertClass)} +
{ } }, [getSaveToComputerHandler]); + const handleInsertClass = useCallback(() => { + if (!editorRef.current) return; + const code = editorRef.current.getValue() || ''; + const target = vm.editingTarget; + if (!target) return; + const wrapped = wrapCurrentCodeWithClass(code, target); + if (wrapped === null) return; // class already exists + const model = editorRef.current.getModel(); + const fullRange = model.getFullModelRange(); + editorRef.current.executeEdits('insertClass', [{ + range: fullRange, + text: wrapped + }]); + }, [vm]); + const handleAISaveFinished = useCallback(() => { onSetAiSaveStatus('saved'); setTimeout(() => { @@ -806,6 +822,7 @@ const RubyTab = props => { editorRef={editorRef.current} onSelectTarget={handleSelectTarget} onDownload={handleDownload} + onInsertClass={handleInsertClass} onExecuteLine={handleExecuteLine} onDismissBubble={handleDismissBubbleStable} onOpenGeminiModal={onOpenGeminiModal} diff --git a/packages/scratch-gui/src/lib/insert-class.js b/packages/scratch-gui/src/lib/insert-class.js new file mode 100644 index 00000000000..095ee0bf439 --- /dev/null +++ b/packages/scratch-gui/src/lib/insert-class.js @@ -0,0 +1,195 @@ +// === Smalruby: This file is Smalruby-specific (class insertion utility for Ruby tab) === + +const INDENT = ' '; + +/** + * Quote a string for Ruby output (double-quoted). + * @param {string} string The string to quote. + * @returns {string} The quoted string. + */ +const quote = string => { + const escapeChars = { + '\\': '\\\\', + '"': '\\"', + '\n': '\\n', + '\t': '\\t' + }; + const s = String(string); + const sb = ['"']; + for (let i = 0; i < s.length; i++) { + const ch = s.charAt(i); + sb.push(escapeChars[ch] || ch); + } + sb.push('"'); + return sb.join(''); +}; + +/** + * Prepend a prefix onto each line of code. + * @param {string} text The lines of code. + * @param {string} prefix The common prefix. + * @returns {string} The prefixed lines of code. + */ +const prefixLines = (text, prefix = INDENT) => + prefix + text.replace(/(?!\n$)\n/g, `\n${prefix}`); + +/** + * Check if a string is a valid Ruby constant name (class name). + * @param {string} name The name to check. + * @returns {boolean} Whether the name is a valid class name. + */ +const isValidClassName = name => /^[A-Z][\p{L}\p{N}_]*$/u.test(name); + +/** + * Check if code already contains a class definition. + * Matches lines starting with optional whitespace followed by "class ". + * Ignores comments (lines starting with #). + * @param {string} code The code to check. + * @returns {boolean} Whether the code contains a class definition. + */ +const hasClassDefinition = code => { + const lines = code.split('\n'); + for (const line of lines) { + const trimmed = line.trimStart(); + if (trimmed.startsWith('#')) continue; + if (/^class\s/.test(trimmed)) return true; + } + return false; +}; + +/** + * Generate set_xxx lines for sprite attributes that differ from defaults. + * @param {object} target The sprite target. + * @param {string[]} setLines Array to push set lines into. + */ +const generateSetXxx = (target, setLines) => { + if (target.x !== 0) { + setLines.push(`set_x ${target.x}`); + } + if (target.y !== 0) { + setLines.push(`set_y ${target.y}`); + } + if (target.direction !== 90) { + setLines.push(`set_direction ${target.direction}`); + } + if (!target.visible) { + setLines.push(`set_visible ${!!target.visible}`); + } + if (target.size !== 100) { + setLines.push(`set_size ${target.size}`); + } + if (target.currentCostume > 0) { + setLines.push(`set_current_costume ${target.currentCostume + 1}`); + } + if (target.rotationStyle !== 'all around') { + setLines.push(`set_rotation_style ${quote(target.rotationStyle)}`); + } +}; + +/** + * Generate set_xxx lines for stage attributes that differ from defaults. + * @param {object} target The stage target. + * @param {string[]} setLines Array to push set lines into. + */ +const generateStageSetXxx = (target, setLines) => { + if (target.currentCostume > 0) { + setLines.push(`set_current_backdrop ${target.currentCostume + 1}`); + } +}; + +/** + * Wrap existing code with a class definition. + * Returns null if code already contains a class definition. + * This replicates the logic of RubyGenerator._wrapWithClass with forFileOutput=true. + * @param {string} code The current editor code. + * @param {object} target The editing target (vm.editingTarget). + * @returns {string|null} The wrapped code, or null if class already exists. + */ +const wrapCurrentCodeWithClass = (code, target) => { + if (hasClassDefinition(code)) { + return null; + } + + const isStage = target.isStage; + let className; + const setLines = []; + + if (isStage) { + className = 'Stage'; + if (target.sprite.name !== 'Stage') { + setLines.push(`set_name ${quote(target.sprite.name)}`); + } + } else { + const spriteName = target.sprite.name; + if (isValidClassName(spriteName)) { + className = spriteName; + } else { + const sprites = target.runtime.targets.filter(t => !t.isStage); + const index = sprites.indexOf(target) + 1; + className = `Sprite${index}`; + setLines.push(`set_name ${quote(spriteName)}`); + } + } + + // Generate set_xxx for non-default attributes + if (isStage) { + generateStageSetXxx(target, setLines); + } else { + generateSetXxx(target, setLines); + } + + let setCode = ''; + if (setLines.length > 0) { + setCode = setLines.map(line => `${INDENT}${line}\n`).join(''); + } + + // Split code into hat/def blocks vs other top-level code + let bodyCode = code; + let outsideCode = ''; + + if (bodyCode.length > 0) { + const sections = bodyCode.split(/\n\n/); + const insideSections = []; + const outsideSections = []; + for (const section of sections) { + const trimmed = section.trim(); + if (trimmed.length === 0) continue; + if (/^self\.when\(/.test(trimmed) || + /^when_/.test(trimmed) || + /^\w+\.when[\s_(]/.test(trimmed) || + /^def /.test(trimmed)) { + insideSections.push(section); + } else { + outsideSections.push(section); + } + } + bodyCode = insideSections.join('\n\n'); + if (bodyCode.length > 0 && !bodyCode.endsWith('\n')) { + bodyCode += '\n'; + } + if (outsideSections.length > 0) { + const commented = outsideSections + .join('\n\n') + .split('\n') + .map(line => (line.trim().length > 0 ? `# ${line}` : '')) + .join('\n'); + outsideCode = `\n${commented}\n`; + } + } + + if (bodyCode.length > 0) { + bodyCode = prefixLines(bodyCode, INDENT); + } + + const separator = setCode.length > 0 && bodyCode.length > 0 ? '\n' : ''; + const inheritance = isStage ? '' : ' < ::Smalruby3::Sprite'; + let result = `class ${className}${inheritance}\n${setCode}${separator}${bodyCode}end\n`; + + if (outsideCode.length > 0) { + result += outsideCode; + } + + return result; +}; + +export {wrapCurrentCodeWithClass, hasClassDefinition}; diff --git a/packages/scratch-gui/src/locales/ja-Hira.js b/packages/scratch-gui/src/locales/ja-Hira.js index 2cd0b20b89d..fc9911bed9c 100644 --- a/packages/scratch-gui/src/locales/ja-Hira.js +++ b/packages/scratch-gui/src/locales/ja-Hira.js @@ -392,6 +392,7 @@ export default { 'gui.rubyToolbar.moreOptions': 'そのたのオプション', 'gui.rubyToolbar.autoCorrectSettings': 'じどうちかんせってい', 'gui.rubyToolbar.saveRubyScript': 'ルビースクリプトをほぞん', + 'gui.rubyToolbar.insertClass': 'クラスをそうにゅう', 'gui.autoCorrectModal.title': 'じどうちかんせってい', 'gui.autoCorrectModal.fullwidthNumbers': 'ぜんかくすうじ → はんかくすうじ', 'gui.autoCorrectModal.fullwidthAlpha': 'ぜんかくアルファベット → はんかくアルファベット', diff --git a/packages/scratch-gui/src/locales/ja.js b/packages/scratch-gui/src/locales/ja.js index d2833fcb85d..d65b4ab96b8 100644 --- a/packages/scratch-gui/src/locales/ja.js +++ b/packages/scratch-gui/src/locales/ja.js @@ -392,6 +392,7 @@ export default { 'gui.rubyToolbar.moreOptions': 'その他のオプション', 'gui.rubyToolbar.autoCorrectSettings': '自動置換設定', 'gui.rubyToolbar.saveRubyScript': 'ルビースクリプトを保存', + 'gui.rubyToolbar.insertClass': 'クラスを挿入', 'gui.autoCorrectModal.title': '自動置換設定', 'gui.autoCorrectModal.fullwidthNumbers': '全角数字 → 半角数字', 'gui.autoCorrectModal.fullwidthAlpha': '全角アルファベット → 半角アルファベット', diff --git a/packages/scratch-gui/test/unit/lib/insert-class.test.js b/packages/scratch-gui/test/unit/lib/insert-class.test.js new file mode 100644 index 00000000000..3e5b498ba68 --- /dev/null +++ b/packages/scratch-gui/test/unit/lib/insert-class.test.js @@ -0,0 +1,178 @@ +// === Smalruby: This file is Smalruby-specific (unit tests for insert-class utility) === + +import {wrapCurrentCodeWithClass} from '../../../src/lib/insert-class'; + +// Helper to create a mock target +const makeTarget = (overrides = {}) => ({ + isStage: false, + x: 0, + y: 0, + direction: 90, + visible: true, + size: 100, + currentCostume: 0, + rotationStyle: 'all around', + sprite: { + name: 'Sprite1', + costumes: [{name: 'costume1'}], + sounds: [{name: 'pop'}] + }, + runtime: { + targets: [ + {isStage: true}, + // will be replaced with the target itself + ] + }, + ...overrides +}); + +const makeStageTarget = (overrides = {}) => ({ + isStage: true, + currentCostume: 0, + sprite: { + name: 'Stage', + costumes: [{name: 'backdrop1'}], + sounds: [{name: 'pop'}] + }, + runtime: { + targets: [{isStage: true}] + }, + ...overrides +}); + +describe('wrapCurrentCodeWithClass', () => { + test('returns null if code already contains a class definition', () => { + const code = 'class Cat\n def setup\n end\nend\n'; + const target = makeTarget(); + expect(wrapCurrentCodeWithClass(code, target)).toBeNull(); + }); + + test('returns null for class with inheritance', () => { + const code = 'class Cat < Animal\nend\n'; + const target = makeTarget(); + expect(wrapCurrentCodeWithClass(code, target)).toBeNull(); + }); + + test('wraps simple code with class', () => { + const code = 'self.when(:flag_clicked) do\n move(10)\nend'; + const target = makeTarget(); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass(code, target); + expect(result).toContain('class Sprite1'); + expect(result).toContain(' self.when(:flag_clicked) do'); + expect(result).toContain(' move(10)'); + expect(result).toContain(' end'); + expect(result).toMatch(/^class Sprite1 < ::Smalruby3::Sprite\n/); + expect(result).toMatch(/\nend\n$/); + }); + + test('wraps empty code with class (no body)', () => { + const code = ''; + const target = makeTarget(); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass(code, target); + expect(result).toBe('class Sprite1 < ::Smalruby3::Sprite\nend\n'); + }); + + test('generates set_x when x is non-default', () => { + const target = makeTarget({x: 100}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_x 100'); + }); + + test('generates set_y when y is non-default', () => { + const target = makeTarget({y: -50}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_y -50'); + }); + + test('generates set_direction when direction is non-default', () => { + const target = makeTarget({direction: 45}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_direction 45'); + }); + + test('generates set_visible when not visible', () => { + const target = makeTarget({visible: false}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_visible false'); + }); + + test('generates set_size when size is non-default', () => { + const target = makeTarget({size: 50}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_size 50'); + }); + + test('generates set_current_costume when costume is non-default', () => { + const target = makeTarget({currentCostume: 2}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_current_costume 3'); + }); + + test('generates set_rotation_style when non-default', () => { + const target = makeTarget({rotationStyle: 'left-right'}); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_rotation_style "left-right"'); + }); + + test('uses sprite name as class name when valid', () => { + const target = makeTarget(); + target.sprite.name = 'Cat'; + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain('class Cat'); + }); + + test('uses Sprite when name is not a valid class name', () => { + const target = makeTarget(); + target.sprite.name = 'my cat'; + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain('class Sprite1'); + expect(result).toContain(' set_name "my cat"'); + }); + + test('comments out non-hat/non-def top-level code', () => { + const code = 'x = 1\n\nself.when(:flag_clicked) do\n move(10)\nend'; + const target = makeTarget(); + target.runtime.targets.push(target); + const result = wrapCurrentCodeWithClass(code, target); + expect(result).toContain('# x = 1'); + expect(result).toContain(' self.when(:flag_clicked) do'); + }); + + test('handles Stage target', () => { + const target = makeStageTarget(); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain('class Stage'); + }); + + test('generates set_current_backdrop for Stage', () => { + const target = makeStageTarget({currentCostume: 1}); + const result = wrapCurrentCodeWithClass('', target); + expect(result).toContain(' set_current_backdrop 2'); + }); + + test('does not match class in comments or strings', () => { + const code = '# class Foo\nself.when(:flag_clicked) do\nend'; + const target = makeTarget(); + target.runtime.targets.push(target); + // A comment containing "class" should NOT be treated as existing class + const result = wrapCurrentCodeWithClass(code, target); + expect(result).not.toBeNull(); + }); + + test('does match actual class definition even with leading whitespace', () => { + const code = ' class Foo\n end'; + const target = makeTarget(); + expect(wrapCurrentCodeWithClass(code, target)).toBeNull(); + }); +});