Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/scratch-gui/src/components/ruby-toolbar/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions packages/scratch-gui/src/components/ruby-toolbar/ruby-toolbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -246,6 +252,13 @@ const RubyToolbar = props => {
/>
{intl.formatMessage(messages.saveRubyScript)}
</div>
<div
className={styles.moreMenuItem}
onClick={handleInsertClass}
>
<span className={styles.moreMenuIcon}>{'{ }'}</span>
{intl.formatMessage(messages.insertClass)}
</div>
<div
className={styles.moreMenuItem}
onClick={handleOpenAutoCorrectSettings}
Expand All @@ -271,6 +284,7 @@ RubyToolbar.propTypes = {
editorRef: PropTypes.object,
onSelectTarget: PropTypes.func.isRequired,
onDownload: PropTypes.func,
onInsertClass: PropTypes.func,
onExecuteLine: PropTypes.func,
onDismissBubble: PropTypes.func,
onOpenGeminiModal: PropTypes.func,
Expand Down
17 changes: 17 additions & 0 deletions packages/scratch-gui/src/containers/ruby-tab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import FuriganaRenderer from './ruby-tab/furigana-renderer';
import GeminiModalHOC from './gemini-modal-hoc.jsx';
import collectMetadata from '../lib/collect-metadata.js';
import {closeFileMenu} from '../reducers/menus.js';
import {wrapCurrentCodeWithClass} from '../lib/insert-class';
import {setAiSaveStatus, clearAiSaveStatus} from '../reducers/koshien-file';
import AutoCorrectModal from '../components/auto-correct-modal/auto-correct-modal.jsx';
import {autoCorrect, defaultSettings as defaultAutoCorrectSettings} from '../lib/auto-correct';
Expand Down Expand Up @@ -436,6 +437,21 @@ const RubyTab = props => {
}
}, [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(() => {
Expand Down Expand Up @@ -806,6 +822,7 @@ const RubyTab = props => {
editorRef={editorRef.current}
onSelectTarget={handleSelectTarget}
onDownload={handleDownload}
onInsertClass={handleInsertClass}
onExecuteLine={handleExecuteLine}
onDismissBubble={handleDismissBubbleStable}
onOpenGeminiModal={onOpenGeminiModal}
Expand Down
195 changes: 195 additions & 0 deletions packages/scratch-gui/src/lib/insert-class.js
Original file line number Diff line number Diff line change
@@ -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};
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/ja-Hira.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': 'ぜんかくアルファベット → はんかくアルファベット',
Expand Down
1 change: 1 addition & 0 deletions packages/scratch-gui/src/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '全角アルファベット → 半角アルファベット',
Expand Down
Loading
Loading